A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds
1/** 2 * E2E tests for PDS - runs against local wrangler dev 3 * Uses Node's built-in test runner and fetch 4 */ 5 6import { describe, it, before, after } from 'node:test'; 7import assert from 'node:assert'; 8import { spawn } from 'node:child_process'; 9import { randomBytes } from 'node:crypto'; 10import { DpopClient } from './helpers/dpop.js'; 11 12const BASE = 'http://localhost:8787'; 13const DID = `did:plc:test${randomBytes(8).toString('hex')}`; 14const PASSWORD = 'test-password'; 15 16/** @type {import('node:child_process').ChildProcess|null} */ 17let wrangler = null; 18/** @type {string} */ 19let token = ''; 20/** @type {string} */ 21let refreshToken = ''; 22/** @type {string} */ 23let testRkey = ''; 24 25/** 26 * Wait for server to be ready 27 */ 28async function waitForServer(maxAttempts = 30) { 29 for (let i = 0; i < maxAttempts; i++) { 30 try { 31 const res = await fetch(`${BASE}/`); 32 if (res.ok) return; 33 } catch { 34 // Server not ready yet 35 } 36 await new Promise((r) => setTimeout(r, 500)); 37 } 38 throw new Error('Server failed to start'); 39} 40 41/** 42 * Make JSON request helper 43 */ 44async function jsonPost(path, body, headers = {}) { 45 const res = await fetch(`${BASE}${path}`, { 46 method: 'POST', 47 headers: { 'Content-Type': 'application/json', ...headers }, 48 body: JSON.stringify(body), 49 }); 50 return { status: res.status, data: res.ok ? await res.json() : null }; 51} 52 53/** 54 * Make form-encoded POST 55 */ 56async function formPost(path, params, headers = {}) { 57 const res = await fetch(`${BASE}${path}`, { 58 method: 'POST', 59 headers: { 60 'Content-Type': 'application/x-www-form-urlencoded', 61 ...headers, 62 }, 63 body: new URLSearchParams(params).toString(), 64 }); 65 const text = await res.text(); 66 let data = null; 67 try { 68 data = JSON.parse(text); 69 } catch { 70 data = text; 71 } 72 return { status: res.status, data }; 73} 74 75describe('E2E Tests', () => { 76 before(async () => { 77 // Start wrangler 78 wrangler = spawn( 79 'npx', 80 ['wrangler', 'dev', '--port', '8787', '--persist-to', '.wrangler/state'], 81 { 82 stdio: 'pipe', 83 cwd: process.cwd(), 84 }, 85 ); 86 87 await waitForServer(); 88 89 // Initialize PDS 90 const privKey = randomBytes(32).toString('hex'); 91 const res = await fetch(`${BASE}/init?did=${DID}`, { 92 method: 'POST', 93 headers: { 'Content-Type': 'application/json' }, 94 body: JSON.stringify({ 95 did: DID, 96 privateKey: privKey, 97 handle: 'test.local', 98 }), 99 }); 100 assert.ok(res.ok, 'PDS initialization failed'); 101 }); 102 103 after(() => { 104 if (wrangler) { 105 wrangler.kill(); 106 } 107 }); 108 109 describe('Server endpoints', () => { 110 it('root returns ASCII art', async () => { 111 const res = await fetch(`${BASE}/`); 112 const text = await res.text(); 113 assert.ok(text.includes('PDS'), 'Root should contain PDS'); 114 }); 115 116 it('describeServer returns DID', async () => { 117 const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); 118 const data = await res.json(); 119 assert.ok(data.did, 'describeServer should return did'); 120 }); 121 122 it('resolveHandle returns DID', async () => { 123 const res = await fetch( 124 `${BASE}/xrpc/com.atproto.identity.resolveHandle?handle=test.local`, 125 ); 126 const data = await res.json(); 127 assert.ok(data.did, 'resolveHandle should return did'); 128 }); 129 }); 130 131 describe('Authentication', () => { 132 it('createSession returns tokens', async () => { 133 const { status, data } = await jsonPost( 134 '/xrpc/com.atproto.server.createSession', 135 { 136 identifier: DID, 137 password: PASSWORD, 138 }, 139 ); 140 assert.strictEqual(status, 200); 141 assert.ok(data.accessJwt, 'Should return accessJwt'); 142 assert.ok(data.refreshJwt, 'Should return refreshJwt'); 143 token = data.accessJwt; 144 refreshToken = data.refreshJwt; 145 }); 146 147 it('getSession with valid token', async () => { 148 const res = await fetch(`${BASE}/xrpc/com.atproto.server.getSession`, { 149 headers: { Authorization: `Bearer ${token}` }, 150 }); 151 const data = await res.json(); 152 assert.ok(data.did, 'getSession should return did'); 153 }); 154 155 it('refreshSession returns new tokens', async () => { 156 const res = await fetch( 157 `${BASE}/xrpc/com.atproto.server.refreshSession`, 158 { 159 method: 'POST', 160 headers: { Authorization: `Bearer ${refreshToken}` }, 161 }, 162 ); 163 const data = await res.json(); 164 assert.ok(data.accessJwt, 'Should return new accessJwt'); 165 assert.ok(data.refreshJwt, 'Should return new refreshJwt'); 166 token = data.accessJwt; // Use new token 167 }); 168 169 it('refreshSession rejects access token', async () => { 170 const res = await fetch( 171 `${BASE}/xrpc/com.atproto.server.refreshSession`, 172 { 173 method: 'POST', 174 headers: { Authorization: `Bearer ${token}` }, 175 }, 176 ); 177 assert.strictEqual(res.status, 400); 178 }); 179 180 it('refreshSession rejects missing auth', async () => { 181 const res = await fetch( 182 `${BASE}/xrpc/com.atproto.server.refreshSession`, 183 { 184 method: 'POST', 185 }, 186 ); 187 assert.strictEqual(res.status, 401); 188 }); 189 190 it('createRecord rejects without auth', async () => { 191 const { status } = await jsonPost('/xrpc/com.atproto.repo.createRecord', { 192 repo: 'x', 193 collection: 'x', 194 record: {}, 195 }); 196 assert.strictEqual(status, 401); 197 }); 198 199 it('getPreferences works', async () => { 200 const res = await fetch(`${BASE}/xrpc/app.bsky.actor.getPreferences`, { 201 headers: { Authorization: `Bearer ${token}` }, 202 }); 203 const data = await res.json(); 204 assert.ok(data.preferences, 'Should return preferences'); 205 }); 206 207 it('putPreferences works', async () => { 208 const { status } = await jsonPost( 209 '/xrpc/app.bsky.actor.putPreferences', 210 { preferences: [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2' }] }, 211 { Authorization: `Bearer ${token}` }, 212 ); 213 assert.strictEqual(status, 200); 214 }); 215 }); 216 217 describe('Record operations', () => { 218 it('createRecord with auth', async () => { 219 const { status, data } = await jsonPost( 220 '/xrpc/com.atproto.repo.createRecord', 221 { 222 repo: DID, 223 collection: 'app.bsky.feed.post', 224 record: { text: 'test', createdAt: new Date().toISOString() }, 225 }, 226 { Authorization: `Bearer ${token}` }, 227 ); 228 assert.strictEqual(status, 200); 229 assert.ok(data.uri, 'Should return uri'); 230 testRkey = data.uri.split('/').pop(); 231 }); 232 233 it('getRecord returns record', async () => { 234 const res = await fetch( 235 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 236 ); 237 const data = await res.json(); 238 assert.ok(data.value?.text, 'Should return record value'); 239 }); 240 241 it('putRecord updates record', async () => { 242 const { status, data } = await jsonPost( 243 '/xrpc/com.atproto.repo.putRecord', 244 { 245 repo: DID, 246 collection: 'app.bsky.feed.post', 247 rkey: testRkey, 248 record: { text: 'updated', createdAt: new Date().toISOString() }, 249 }, 250 { Authorization: `Bearer ${token}` }, 251 ); 252 assert.strictEqual(status, 200); 253 assert.ok(data.uri); 254 }); 255 256 it('listRecords returns records', async () => { 257 const res = await fetch( 258 `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=app.bsky.feed.post`, 259 ); 260 const data = await res.json(); 261 assert.ok(data.records?.length > 0, 'Should return records'); 262 }); 263 264 it('describeRepo returns did', async () => { 265 const res = await fetch( 266 `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, 267 ); 268 const data = await res.json(); 269 assert.ok(data.did); 270 }); 271 272 it('applyWrites create', async () => { 273 const { status, data } = await jsonPost( 274 '/xrpc/com.atproto.repo.applyWrites', 275 { 276 repo: DID, 277 writes: [ 278 { 279 $type: 'com.atproto.repo.applyWrites#create', 280 collection: 'app.bsky.feed.post', 281 rkey: 'applytest', 282 value: { text: 'batch', createdAt: new Date().toISOString() }, 283 }, 284 ], 285 }, 286 { Authorization: `Bearer ${token}` }, 287 ); 288 assert.strictEqual(status, 200); 289 assert.ok(data.results); 290 }); 291 292 it('applyWrites delete', async () => { 293 const { status, data } = await jsonPost( 294 '/xrpc/com.atproto.repo.applyWrites', 295 { 296 repo: DID, 297 writes: [ 298 { 299 $type: 'com.atproto.repo.applyWrites#delete', 300 collection: 'app.bsky.feed.post', 301 rkey: 'applytest', 302 }, 303 ], 304 }, 305 { Authorization: `Bearer ${token}` }, 306 ); 307 assert.strictEqual(status, 200); 308 assert.ok(data.results); 309 }); 310 }); 311 312 describe('Sync endpoints', () => { 313 it('getLatestCommit returns cid', async () => { 314 const res = await fetch( 315 `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, 316 ); 317 const data = await res.json(); 318 assert.ok(data.cid); 319 }); 320 321 it('getRepoStatus returns did', async () => { 322 const res = await fetch( 323 `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, 324 ); 325 const data = await res.json(); 326 assert.ok(data.did); 327 }); 328 329 it('getRepo returns CAR', async () => { 330 const res = await fetch( 331 `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, 332 ); 333 const data = await res.arrayBuffer(); 334 assert.ok(data.byteLength > 100, 'Should return CAR data'); 335 }); 336 337 it('getRecord returns record CAR', async () => { 338 const res = await fetch( 339 `${BASE}/xrpc/com.atproto.sync.getRecord?did=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 340 ); 341 const data = await res.arrayBuffer(); 342 assert.ok(data.byteLength > 50); 343 }); 344 345 it('listRepos returns repos', async () => { 346 const res = await fetch(`${BASE}/xrpc/com.atproto.sync.listRepos`); 347 const data = await res.json(); 348 assert.ok(data.repos?.length > 0); 349 }); 350 }); 351 352 describe('Error handling', () => { 353 it('invalid password rejected (401)', async () => { 354 const { status } = await jsonPost( 355 '/xrpc/com.atproto.server.createSession', 356 { 357 identifier: DID, 358 password: 'wrong-password', 359 }, 360 ); 361 assert.strictEqual(status, 401); 362 }); 363 364 it('wrong repo rejected (403)', async () => { 365 const { status } = await jsonPost( 366 '/xrpc/com.atproto.repo.createRecord', 367 { 368 repo: 'did:plc:z72i7hdynmk6r22z27h6tvur', 369 collection: 'app.bsky.feed.post', 370 record: { text: 'x', createdAt: '2024-01-01T00:00:00Z' }, 371 }, 372 { Authorization: `Bearer ${token}` }, 373 ); 374 assert.strictEqual(status, 403); 375 }); 376 377 it('non-existent record errors', async () => { 378 const res = await fetch( 379 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=nonexistent`, 380 ); 381 assert.ok([400, 404].includes(res.status)); 382 }); 383 }); 384 385 describe('Blob endpoints', () => { 386 /** @type {string} */ 387 let blobCid = ''; 388 /** @type {string} */ 389 let blobPostRkey = ''; 390 391 // Create minimal PNG 392 const pngBytes = new Uint8Array([ 393 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 394 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 395 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 396 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 397 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 398 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 399 ]); 400 401 it('uploadBlob rejects without auth', async () => { 402 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 403 method: 'POST', 404 headers: { 'Content-Type': 'image/png' }, 405 body: pngBytes, 406 }); 407 assert.strictEqual(res.status, 401); 408 }); 409 410 it('uploadBlob returns CID', async () => { 411 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 412 method: 'POST', 413 headers: { 414 'Content-Type': 'image/png', 415 Authorization: `Bearer ${token}`, 416 }, 417 body: pngBytes, 418 }); 419 const data = await res.json(); 420 assert.ok(data.blob?.ref?.$link); 421 assert.strictEqual(data.blob?.mimeType, 'image/png'); 422 blobCid = data.blob.ref.$link; 423 }); 424 425 it('listBlobs includes uploaded blob', async () => { 426 const res = await fetch( 427 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 428 ); 429 const data = await res.json(); 430 assert.ok(data.cids?.includes(blobCid)); 431 }); 432 433 it('getBlob retrieves data', async () => { 434 const res = await fetch( 435 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, 436 ); 437 assert.ok(res.ok); 438 assert.strictEqual(res.headers.get('content-type'), 'image/png'); 439 assert.strictEqual(res.headers.get('x-content-type-options'), 'nosniff'); 440 }); 441 442 it('getBlob rejects wrong DID', async () => { 443 const res = await fetch( 444 `${BASE}/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=${blobCid}`, 445 ); 446 assert.strictEqual(res.status, 400); 447 }); 448 449 it('getBlob rejects invalid CID', async () => { 450 const res = await fetch( 451 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=invalid`, 452 ); 453 assert.strictEqual(res.status, 400); 454 }); 455 456 it('getBlob 404 for missing blob', async () => { 457 const res = await fetch( 458 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, 459 ); 460 assert.strictEqual(res.status, 404); 461 }); 462 463 it('createRecord with blob ref', async () => { 464 const { status, data } = await jsonPost( 465 '/xrpc/com.atproto.repo.createRecord', 466 { 467 repo: DID, 468 collection: 'app.bsky.feed.post', 469 record: { 470 text: 'post with image', 471 createdAt: new Date().toISOString(), 472 embed: { 473 $type: 'app.bsky.embed.images', 474 images: [ 475 { 476 image: { 477 $type: 'blob', 478 ref: { $link: blobCid }, 479 mimeType: 'image/png', 480 size: pngBytes.length, 481 }, 482 alt: 'test', 483 }, 484 ], 485 }, 486 }, 487 }, 488 { Authorization: `Bearer ${token}` }, 489 ); 490 assert.strictEqual(status, 200); 491 blobPostRkey = data.uri.split('/').pop(); 492 }); 493 494 it('blob persists after record creation', async () => { 495 const res = await fetch( 496 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 497 ); 498 const data = await res.json(); 499 assert.ok(data.cids?.includes(blobCid)); 500 }); 501 502 it('deleteRecord with blob cleans up', async () => { 503 const { status } = await jsonPost( 504 '/xrpc/com.atproto.repo.deleteRecord', 505 { repo: DID, collection: 'app.bsky.feed.post', rkey: blobPostRkey }, 506 { Authorization: `Bearer ${token}` }, 507 ); 508 assert.strictEqual(status, 200); 509 510 const res = await fetch( 511 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 512 ); 513 const data = await res.json(); 514 assert.strictEqual( 515 data.cids?.length, 516 0, 517 'Orphaned blob should be cleaned up', 518 ); 519 }); 520 }); 521 522 describe('OAuth endpoints', () => { 523 it('AS metadata', async () => { 524 const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); 525 const data = await res.json(); 526 assert.strictEqual(data.issuer, BASE); 527 assert.strictEqual( 528 data.authorization_endpoint, 529 `${BASE}/oauth/authorize`, 530 ); 531 assert.strictEqual(data.token_endpoint, `${BASE}/oauth/token`); 532 assert.strictEqual( 533 data.pushed_authorization_request_endpoint, 534 `${BASE}/oauth/par`, 535 ); 536 assert.strictEqual(data.revocation_endpoint, `${BASE}/oauth/revoke`); 537 assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`); 538 assert.deepStrictEqual(data.scopes_supported, ['atproto']); 539 assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']); 540 assert.strictEqual(data.require_pushed_authorization_requests, true); 541 assert.strictEqual(data.client_id_metadata_document_supported, true); 542 assert.deepStrictEqual(data.protected_resources, [BASE]); 543 }); 544 545 it('PR metadata', async () => { 546 const res = await fetch(`${BASE}/.well-known/oauth-protected-resource`); 547 const data = await res.json(); 548 assert.strictEqual(data.resource, BASE); 549 assert.deepStrictEqual(data.authorization_servers, [BASE]); 550 }); 551 552 it('JWKS endpoint', async () => { 553 const res = await fetch(`${BASE}/oauth/jwks`); 554 const data = await res.json(); 555 assert.ok(data.keys?.length > 0); 556 const key = data.keys[0]; 557 assert.strictEqual(key.kty, 'EC'); 558 assert.strictEqual(key.crv, 'P-256'); 559 assert.strictEqual(key.alg, 'ES256'); 560 assert.strictEqual(key.use, 'sig'); 561 assert.ok(key.x && key.y, 'Should have x,y coords'); 562 assert.ok(!key.d, 'Should not expose private key'); 563 }); 564 565 it('PAR rejects missing DPoP', async () => { 566 const { status, data } = await formPost('/oauth/par', { 567 client_id: 'http://localhost:3000', 568 redirect_uri: 'http://localhost:3000/callback', 569 response_type: 'code', 570 scope: 'atproto', 571 code_challenge: 'test', 572 code_challenge_method: 'S256', 573 }); 574 assert.strictEqual(status, 400); 575 assert.strictEqual(data.error, 'invalid_dpop_proof'); 576 }); 577 578 it('token rejects missing DPoP', async () => { 579 const { status, data } = await formPost('/oauth/token', { 580 grant_type: 'authorization_code', 581 code: 'fake', 582 client_id: 'http://localhost:3000', 583 }); 584 assert.strictEqual(status, 400); 585 assert.strictEqual(data.error, 'invalid_dpop_proof'); 586 }); 587 588 it('revoke returns 200 for invalid token', async () => { 589 const { status } = await formPost('/oauth/revoke', { 590 token: 'nonexistent', 591 client_id: 'http://localhost:3000', 592 }); 593 assert.strictEqual(status, 200); 594 }); 595 }); 596 597 describe('OAuth flow with DPoP', () => { 598 it('full PAR -> authorize -> token flow', async () => { 599 const dpop = await DpopClient.create(); 600 const clientId = 'http://localhost:3000'; 601 const redirectUri = 'http://localhost:3000/callback'; 602 const codeVerifier = randomBytes(32).toString('base64url'); 603 604 // Generate code_challenge from verifier (S256) 605 const challengeBuffer = await crypto.subtle.digest( 606 'SHA-256', 607 new TextEncoder().encode(codeVerifier), 608 ); 609 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 610 611 // Step 1: PAR request 612 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 613 const parRes = await fetch(`${BASE}/oauth/par`, { 614 method: 'POST', 615 headers: { 616 'Content-Type': 'application/x-www-form-urlencoded', 617 DPoP: parProof, 618 }, 619 body: new URLSearchParams({ 620 client_id: clientId, 621 redirect_uri: redirectUri, 622 response_type: 'code', 623 scope: 'atproto', 624 code_challenge: codeChallenge, 625 code_challenge_method: 'S256', 626 state: 'test-state', 627 login_hint: DID, 628 }).toString(), 629 }); 630 631 assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 632 const parData = await parRes.json(); 633 assert.ok(parData.request_uri, 'PAR should return request_uri'); 634 assert.ok(parData.expires_in > 0, 'PAR should return expires_in'); 635 636 // Step 2: Authorization (simulate user consent by POSTing to authorize) 637 const authRes = await fetch(`${BASE}/oauth/authorize`, { 638 method: 'POST', 639 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 640 body: new URLSearchParams({ 641 request_uri: parData.request_uri, 642 client_id: clientId, 643 password: PASSWORD, 644 }).toString(), 645 redirect: 'manual', 646 }); 647 648 assert.strictEqual(authRes.status, 302, 'Authorize should redirect'); 649 const location = authRes.headers.get('location'); 650 assert.ok(location, 'Should have Location header'); 651 652 const redirectUrl = new URL(location); 653 const authCode = redirectUrl.searchParams.get('code'); 654 assert.ok(authCode, 'Redirect should have code'); 655 assert.strictEqual(redirectUrl.searchParams.get('state'), 'test-state'); 656 assert.strictEqual(redirectUrl.searchParams.get('iss'), BASE); 657 658 // Step 3: Token exchange 659 const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 660 const tokenRes = await fetch(`${BASE}/oauth/token`, { 661 method: 'POST', 662 headers: { 663 'Content-Type': 'application/x-www-form-urlencoded', 664 DPoP: tokenProof, 665 }, 666 body: new URLSearchParams({ 667 grant_type: 'authorization_code', 668 code: authCode, 669 client_id: clientId, 670 redirect_uri: redirectUri, 671 code_verifier: codeVerifier, 672 }).toString(), 673 }); 674 675 assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 676 const tokenData = await tokenRes.json(); 677 assert.ok(tokenData.access_token, 'Should return access_token'); 678 assert.ok(tokenData.refresh_token, 'Should return refresh_token'); 679 assert.strictEqual(tokenData.token_type, 'DPoP'); 680 assert.strictEqual(tokenData.scope, 'atproto'); 681 assert.ok(tokenData.sub, 'Should return sub'); 682 683 // Step 4: Use access token with DPoP for protected endpoint 684 const resourceProof = await dpop.createProof( 685 'GET', 686 `${BASE}/xrpc/com.atproto.server.getSession`, 687 tokenData.access_token, 688 ); 689 const sessionRes = await fetch( 690 `${BASE}/xrpc/com.atproto.server.getSession`, 691 { 692 headers: { 693 Authorization: `DPoP ${tokenData.access_token}`, 694 DPoP: resourceProof, 695 }, 696 }, 697 ); 698 699 assert.strictEqual( 700 sessionRes.status, 701 200, 702 'Protected endpoint should work with DPoP token', 703 ); 704 const sessionData = await sessionRes.json(); 705 assert.ok(sessionData.did, 'Should return session data'); 706 707 // Step 5: Refresh token 708 const refreshProof = await dpop.createProof( 709 'POST', 710 `${BASE}/oauth/token`, 711 ); 712 const refreshRes = await fetch(`${BASE}/oauth/token`, { 713 method: 'POST', 714 headers: { 715 'Content-Type': 'application/x-www-form-urlencoded', 716 DPoP: refreshProof, 717 }, 718 body: new URLSearchParams({ 719 grant_type: 'refresh_token', 720 refresh_token: tokenData.refresh_token, 721 client_id: clientId, 722 }).toString(), 723 }); 724 725 assert.strictEqual(refreshRes.status, 200, 'Refresh should succeed'); 726 const refreshData = await refreshRes.json(); 727 assert.ok(refreshData.access_token, 'Should return new access_token'); 728 assert.ok(refreshData.refresh_token, 'Should return new refresh_token'); 729 730 // Step 6: Revoke token 731 const revokeRes = await fetch(`${BASE}/oauth/revoke`, { 732 method: 'POST', 733 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 734 body: new URLSearchParams({ 735 token: refreshData.refresh_token, 736 client_id: clientId, 737 }).toString(), 738 }); 739 assert.strictEqual(revokeRes.status, 200); 740 }); 741 742 it('DPoP key mismatch rejected', async () => { 743 const dpop1 = await DpopClient.create(); 744 const dpop2 = await DpopClient.create(); 745 const clientId = 'http://localhost:3000'; 746 const redirectUri = 'http://localhost:3000/callback'; 747 const codeVerifier = randomBytes(32).toString('base64url'); 748 const challengeBuffer = await crypto.subtle.digest( 749 'SHA-256', 750 new TextEncoder().encode(codeVerifier), 751 ); 752 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 753 754 // PAR with first key 755 const parProof = await dpop1.createProof('POST', `${BASE}/oauth/par`); 756 const parRes = await fetch(`${BASE}/oauth/par`, { 757 method: 'POST', 758 headers: { 759 'Content-Type': 'application/x-www-form-urlencoded', 760 DPoP: parProof, 761 }, 762 body: new URLSearchParams({ 763 client_id: clientId, 764 redirect_uri: redirectUri, 765 response_type: 'code', 766 scope: 'atproto', 767 code_challenge: codeChallenge, 768 code_challenge_method: 'S256', 769 login_hint: DID, 770 }).toString(), 771 }); 772 const parData = await parRes.json(); 773 774 // Authorize 775 const authRes = await fetch(`${BASE}/oauth/authorize`, { 776 method: 'POST', 777 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 778 body: new URLSearchParams({ 779 request_uri: parData.request_uri, 780 client_id: clientId, 781 password: PASSWORD, 782 }).toString(), 783 redirect: 'manual', 784 }); 785 const location = authRes.headers.get('location'); 786 const authCode = new URL(location).searchParams.get('code'); 787 788 // Token with DIFFERENT key should fail 789 const tokenProof = await dpop2.createProof('POST', `${BASE}/oauth/token`); 790 const tokenRes = await fetch(`${BASE}/oauth/token`, { 791 method: 'POST', 792 headers: { 793 'Content-Type': 'application/x-www-form-urlencoded', 794 DPoP: tokenProof, 795 }, 796 body: new URLSearchParams({ 797 grant_type: 'authorization_code', 798 code: authCode, 799 client_id: clientId, 800 redirect_uri: redirectUri, 801 code_verifier: codeVerifier, 802 }).toString(), 803 }); 804 805 assert.strictEqual(tokenRes.status, 400); 806 const tokenData = await tokenRes.json(); 807 assert.strictEqual(tokenData.error, 'invalid_dpop_proof'); 808 }); 809 810 it('fragment response_mode returns code in fragment', async () => { 811 const dpop = await DpopClient.create(); 812 const clientId = 'http://localhost:3000'; 813 const redirectUri = 'http://localhost:3000/callback'; 814 const codeVerifier = randomBytes(32).toString('base64url'); 815 const challengeBuffer = await crypto.subtle.digest( 816 'SHA-256', 817 new TextEncoder().encode(codeVerifier), 818 ); 819 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 820 821 // PAR with response_mode=fragment 822 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 823 const parRes = await fetch(`${BASE}/oauth/par`, { 824 method: 'POST', 825 headers: { 826 'Content-Type': 'application/x-www-form-urlencoded', 827 DPoP: parProof, 828 }, 829 body: new URLSearchParams({ 830 client_id: clientId, 831 redirect_uri: redirectUri, 832 response_type: 'code', 833 response_mode: 'fragment', 834 scope: 'atproto', 835 code_challenge: codeChallenge, 836 code_challenge_method: 'S256', 837 login_hint: DID, 838 }).toString(), 839 }); 840 const parData = await parRes.json(); 841 assert.ok(parData.request_uri); 842 843 // Authorize 844 const authRes = await fetch(`${BASE}/oauth/authorize`, { 845 method: 'POST', 846 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 847 body: new URLSearchParams({ 848 request_uri: parData.request_uri, 849 client_id: clientId, 850 password: PASSWORD, 851 }).toString(), 852 redirect: 'manual', 853 }); 854 855 assert.strictEqual(authRes.status, 302); 856 const location = authRes.headers.get('location'); 857 assert.ok(location); 858 // For fragment mode, code should be in hash fragment 859 assert.ok(location.includes('#'), 'Should use fragment'); 860 const url = new URL(location); 861 const fragment = new URLSearchParams(url.hash.slice(1)); 862 assert.ok(fragment.get('code'), 'Code should be in fragment'); 863 assert.ok(fragment.get('iss'), 'Issuer should be in fragment'); 864 }); 865 866 it('PKCE failure - wrong code_verifier rejected', async () => { 867 const dpop = await DpopClient.create(); 868 const clientId = 'http://localhost:3000'; 869 const redirectUri = 'http://localhost:3000/callback'; 870 const codeVerifier = randomBytes(32).toString('base64url'); 871 const wrongVerifier = randomBytes(32).toString('base64url'); 872 const challengeBuffer = await crypto.subtle.digest( 873 'SHA-256', 874 new TextEncoder().encode(codeVerifier), 875 ); 876 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 877 878 // PAR 879 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 880 const parRes = await fetch(`${BASE}/oauth/par`, { 881 method: 'POST', 882 headers: { 883 'Content-Type': 'application/x-www-form-urlencoded', 884 DPoP: parProof, 885 }, 886 body: new URLSearchParams({ 887 client_id: clientId, 888 redirect_uri: redirectUri, 889 response_type: 'code', 890 scope: 'atproto', 891 code_challenge: codeChallenge, 892 code_challenge_method: 'S256', 893 login_hint: DID, 894 }).toString(), 895 }); 896 const parData = await parRes.json(); 897 898 // Authorize 899 const authRes = await fetch(`${BASE}/oauth/authorize`, { 900 method: 'POST', 901 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 902 body: new URLSearchParams({ 903 request_uri: parData.request_uri, 904 client_id: clientId, 905 password: PASSWORD, 906 }).toString(), 907 redirect: 'manual', 908 }); 909 const location = authRes.headers.get('location'); 910 const authCode = new URL(location).searchParams.get('code'); 911 912 // Token with WRONG code_verifier should fail 913 const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 914 const tokenRes = await fetch(`${BASE}/oauth/token`, { 915 method: 'POST', 916 headers: { 917 'Content-Type': 'application/x-www-form-urlencoded', 918 DPoP: tokenProof, 919 }, 920 body: new URLSearchParams({ 921 grant_type: 'authorization_code', 922 code: authCode, 923 client_id: clientId, 924 redirect_uri: redirectUri, 925 code_verifier: wrongVerifier, 926 }).toString(), 927 }); 928 929 assert.strictEqual(tokenRes.status, 400); 930 const tokenData = await tokenRes.json(); 931 assert.strictEqual(tokenData.error, 'invalid_grant'); 932 assert.ok(tokenData.message?.includes('code_verifier')); 933 }); 934 935 it('redirect_uri mismatch rejected', async () => { 936 const dpop = await DpopClient.create(); 937 const clientId = 'http://localhost:3000'; 938 const codeVerifier = randomBytes(32).toString('base64url'); 939 const challengeBuffer = await crypto.subtle.digest( 940 'SHA-256', 941 new TextEncoder().encode(codeVerifier), 942 ); 943 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 944 945 // PAR with unregistered redirect_uri 946 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 947 const parRes = await fetch(`${BASE}/oauth/par`, { 948 method: 'POST', 949 headers: { 950 'Content-Type': 'application/x-www-form-urlencoded', 951 DPoP: parProof, 952 }, 953 body: new URLSearchParams({ 954 client_id: clientId, 955 redirect_uri: 'http://attacker.com/callback', 956 response_type: 'code', 957 scope: 'atproto', 958 code_challenge: codeChallenge, 959 code_challenge_method: 'S256', 960 login_hint: DID, 961 }).toString(), 962 }); 963 964 assert.strictEqual(parRes.status, 400); 965 const parData = await parRes.json(); 966 assert.strictEqual(parData.error, 'invalid_request'); 967 assert.ok(parData.message?.includes('redirect_uri')); 968 }); 969 970 it('DPoP jti replay rejected', async () => { 971 const dpop = await DpopClient.create(); 972 const clientId = 'http://localhost:3000'; 973 const redirectUri = 'http://localhost:3000/callback'; 974 const codeVerifier = randomBytes(32).toString('base64url'); 975 const challengeBuffer = await crypto.subtle.digest( 976 'SHA-256', 977 new TextEncoder().encode(codeVerifier), 978 ); 979 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 980 981 // Create a single DPoP proof 982 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 983 984 // First request should succeed 985 const parRes1 = await fetch(`${BASE}/oauth/par`, { 986 method: 'POST', 987 headers: { 988 'Content-Type': 'application/x-www-form-urlencoded', 989 DPoP: parProof, 990 }, 991 body: new URLSearchParams({ 992 client_id: clientId, 993 redirect_uri: redirectUri, 994 response_type: 'code', 995 scope: 'atproto', 996 code_challenge: codeChallenge, 997 code_challenge_method: 'S256', 998 login_hint: DID, 999 }).toString(), 1000 }); 1001 assert.strictEqual(parRes1.status, 200); 1002 1003 // Second request with SAME proof should be rejected 1004 const parRes2 = await fetch(`${BASE}/oauth/par`, { 1005 method: 'POST', 1006 headers: { 1007 'Content-Type': 'application/x-www-form-urlencoded', 1008 DPoP: parProof, 1009 }, 1010 body: new URLSearchParams({ 1011 client_id: clientId, 1012 redirect_uri: redirectUri, 1013 response_type: 'code', 1014 scope: 'atproto', 1015 code_challenge: codeChallenge, 1016 code_challenge_method: 'S256', 1017 login_hint: DID, 1018 }).toString(), 1019 }); 1020 1021 assert.strictEqual(parRes2.status, 400); 1022 const data = await parRes2.json(); 1023 assert.strictEqual(data.error, 'invalid_dpop_proof'); 1024 assert.ok(data.message?.includes('replay')); 1025 }); 1026 }); 1027 1028 describe('Cleanup', () => { 1029 it('deleteRecord (cleanup)', async () => { 1030 const { status } = await jsonPost( 1031 '/xrpc/com.atproto.repo.deleteRecord', 1032 { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey }, 1033 { Authorization: `Bearer ${token}` }, 1034 ); 1035 assert.strictEqual(status, 200); 1036 }); 1037 }); 1038});