A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds
at main 64 kB view raw
1/** 2 * E2E tests for PDS - runs against local wrangler dev 3 * Uses Node's built-in test runner and fetch 4 */ 5 6import assert from 'node:assert'; 7import { spawn } from 'node:child_process'; 8import { randomBytes } from 'node:crypto'; 9import { after, before, describe, it } from 'node:test'; 10import { DpopClient } from './helpers/dpop.js'; 11import { getOAuthTokenWithScope } from './helpers/oauth.js'; 12 13const BASE = 'http://localhost:8787'; 14const DID = `did:plc:test${randomBytes(8).toString('hex')}`; 15const PASSWORD = 'test-password'; 16 17/** @type {import('node:child_process').ChildProcess|null} */ 18let wrangler = null; 19/** @type {string} */ 20let token = ''; 21/** @type {string} */ 22let refreshToken = ''; 23/** @type {string} */ 24let testRkey = ''; 25 26/** 27 * Wait for server to be ready 28 */ 29async function waitForServer(maxAttempts = 30) { 30 for (let i = 0; i < maxAttempts; i++) { 31 try { 32 const res = await fetch(`${BASE}/`); 33 if (res.ok) return; 34 } catch { 35 // Server not ready yet 36 } 37 await new Promise((r) => setTimeout(r, 500)); 38 } 39 throw new Error('Server failed to start'); 40} 41 42/** 43 * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) 44 */ 45async function jsonPost(path, body, headers = {}) { 46 for (let attempt = 0; attempt < 3; attempt++) { 47 const res = await fetch(`${BASE}${path}`, { 48 method: 'POST', 49 headers: { 'Content-Type': 'application/json', ...headers }, 50 body: JSON.stringify(body), 51 }); 52 // Retry on 5xx errors (wrangler dev flakiness) 53 if (res.status >= 500 && attempt < 2) { 54 await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 55 continue; 56 } 57 return { status: res.status, data: res.ok ? await res.json() : null }; 58 } 59} 60 61/** 62 * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) 63 */ 64async function formPost(path, params, headers = {}) { 65 for (let attempt = 0; attempt < 3; attempt++) { 66 const res = await fetch(`${BASE}${path}`, { 67 method: 'POST', 68 headers: { 69 'Content-Type': 'application/x-www-form-urlencoded', 70 ...headers, 71 }, 72 body: new URLSearchParams(params).toString(), 73 }); 74 // Retry on 5xx errors (wrangler dev flakiness) 75 if (res.status >= 500 && attempt < 2) { 76 await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 77 continue; 78 } 79 const text = await res.text(); 80 let data = null; 81 try { 82 data = JSON.parse(text); 83 } catch { 84 data = text; 85 } 86 return { status: res.status, data }; 87 } 88} 89 90describe('E2E Tests', () => { 91 before(async () => { 92 // Start wrangler 93 wrangler = spawn( 94 'npx', 95 ['wrangler', 'dev', '--port', '8787', '--persist-to', '.wrangler/state'], 96 { 97 stdio: 'pipe', 98 cwd: process.cwd(), 99 }, 100 ); 101 102 await waitForServer(); 103 104 // Initialize PDS 105 const privKey = randomBytes(32).toString('hex'); 106 const res = await fetch(`${BASE}/init?did=${DID}`, { 107 method: 'POST', 108 headers: { 'Content-Type': 'application/json' }, 109 body: JSON.stringify({ 110 did: DID, 111 privateKey: privKey, 112 handle: 'test.local', 113 }), 114 }); 115 assert.ok(res.ok, 'PDS initialization failed'); 116 }); 117 118 after(() => { 119 if (wrangler) { 120 wrangler.kill(); 121 } 122 }); 123 124 describe('Server endpoints', () => { 125 it('root returns ASCII art', async () => { 126 const res = await fetch(`${BASE}/`); 127 const text = await res.text(); 128 assert.ok(text.includes('PDS'), 'Root should contain PDS'); 129 }); 130 131 it('describeServer returns DID', async () => { 132 const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); 133 const data = await res.json(); 134 assert.ok(data.did, 'describeServer should return did'); 135 }); 136 137 it('resolveHandle returns DID', async () => { 138 const res = await fetch( 139 `${BASE}/xrpc/com.atproto.identity.resolveHandle?handle=test.local`, 140 ); 141 const data = await res.json(); 142 assert.ok(data.did, 'resolveHandle should return did'); 143 }); 144 }); 145 146 describe('Authentication', () => { 147 it('createSession returns tokens', async () => { 148 const { status, data } = await jsonPost( 149 '/xrpc/com.atproto.server.createSession', 150 { 151 identifier: DID, 152 password: PASSWORD, 153 }, 154 ); 155 assert.strictEqual(status, 200); 156 assert.ok(data.accessJwt, 'Should return accessJwt'); 157 assert.ok(data.refreshJwt, 'Should return refreshJwt'); 158 token = data.accessJwt; 159 refreshToken = data.refreshJwt; 160 }); 161 162 it('getSession with valid token', async () => { 163 const res = await fetch(`${BASE}/xrpc/com.atproto.server.getSession`, { 164 headers: { Authorization: `Bearer ${token}` }, 165 }); 166 const data = await res.json(); 167 assert.ok(data.did, 'getSession should return did'); 168 }); 169 170 it('refreshSession returns new tokens', async () => { 171 const res = await fetch( 172 `${BASE}/xrpc/com.atproto.server.refreshSession`, 173 { 174 method: 'POST', 175 headers: { Authorization: `Bearer ${refreshToken}` }, 176 }, 177 ); 178 const data = await res.json(); 179 assert.ok(data.accessJwt, 'Should return new accessJwt'); 180 assert.ok(data.refreshJwt, 'Should return new refreshJwt'); 181 token = data.accessJwt; // Use new token 182 }); 183 184 it('refreshSession rejects access token', async () => { 185 const res = await fetch( 186 `${BASE}/xrpc/com.atproto.server.refreshSession`, 187 { 188 method: 'POST', 189 headers: { Authorization: `Bearer ${token}` }, 190 }, 191 ); 192 assert.strictEqual(res.status, 400); 193 }); 194 195 it('refreshSession rejects missing auth', async () => { 196 const res = await fetch( 197 `${BASE}/xrpc/com.atproto.server.refreshSession`, 198 { 199 method: 'POST', 200 }, 201 ); 202 assert.strictEqual(res.status, 401); 203 }); 204 205 it('createRecord rejects without auth', async () => { 206 const { status } = await jsonPost('/xrpc/com.atproto.repo.createRecord', { 207 repo: 'x', 208 collection: 'x', 209 record: {}, 210 }); 211 assert.strictEqual(status, 401); 212 }); 213 214 it('getPreferences works', async () => { 215 const res = await fetch(`${BASE}/xrpc/app.bsky.actor.getPreferences`, { 216 headers: { Authorization: `Bearer ${token}` }, 217 }); 218 const data = await res.json(); 219 assert.ok(data.preferences, 'Should return preferences'); 220 }); 221 222 it('putPreferences works', async () => { 223 const { status } = await jsonPost( 224 '/xrpc/app.bsky.actor.putPreferences', 225 { preferences: [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2' }] }, 226 { Authorization: `Bearer ${token}` }, 227 ); 228 assert.strictEqual(status, 200); 229 }); 230 }); 231 232 describe('Record operations', () => { 233 it('createRecord with auth', async () => { 234 const { status, data } = await jsonPost( 235 '/xrpc/com.atproto.repo.createRecord', 236 { 237 repo: DID, 238 collection: 'app.bsky.feed.post', 239 record: { text: 'test', createdAt: new Date().toISOString() }, 240 }, 241 { Authorization: `Bearer ${token}` }, 242 ); 243 assert.strictEqual(status, 200); 244 assert.ok(data.uri, 'Should return uri'); 245 testRkey = data.uri.split('/').pop(); 246 }); 247 248 it('getRecord returns record', async () => { 249 const res = await fetch( 250 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 251 ); 252 const data = await res.json(); 253 assert.ok(data.value?.text, 'Should return record value'); 254 }); 255 256 it('putRecord updates record', async () => { 257 const { status, data } = await jsonPost( 258 '/xrpc/com.atproto.repo.putRecord', 259 { 260 repo: DID, 261 collection: 'app.bsky.feed.post', 262 rkey: testRkey, 263 record: { text: 'updated', createdAt: new Date().toISOString() }, 264 }, 265 { Authorization: `Bearer ${token}` }, 266 ); 267 assert.strictEqual(status, 200); 268 assert.ok(data.uri); 269 }); 270 271 it('listRecords returns records', async () => { 272 const res = await fetch( 273 `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=app.bsky.feed.post`, 274 ); 275 const data = await res.json(); 276 assert.ok(data.records?.length > 0, 'Should return records'); 277 }); 278 279 it('describeRepo returns did', async () => { 280 const res = await fetch( 281 `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, 282 ); 283 const data = await res.json(); 284 assert.ok(data.did); 285 }); 286 287 it('applyWrites create', async () => { 288 const { status, data } = await jsonPost( 289 '/xrpc/com.atproto.repo.applyWrites', 290 { 291 repo: DID, 292 writes: [ 293 { 294 $type: 'com.atproto.repo.applyWrites#create', 295 collection: 'app.bsky.feed.post', 296 rkey: 'applytest', 297 value: { text: 'batch', createdAt: new Date().toISOString() }, 298 }, 299 ], 300 }, 301 { Authorization: `Bearer ${token}` }, 302 ); 303 assert.strictEqual(status, 200); 304 assert.ok(data.results); 305 }); 306 307 it('applyWrites delete', async () => { 308 const { status, data } = await jsonPost( 309 '/xrpc/com.atproto.repo.applyWrites', 310 { 311 repo: DID, 312 writes: [ 313 { 314 $type: 'com.atproto.repo.applyWrites#delete', 315 collection: 'app.bsky.feed.post', 316 rkey: 'applytest', 317 }, 318 ], 319 }, 320 { Authorization: `Bearer ${token}` }, 321 ); 322 assert.strictEqual(status, 200); 323 assert.ok(data.results); 324 }); 325 }); 326 327 describe('Sync endpoints', () => { 328 it('getLatestCommit returns cid', async () => { 329 const res = await fetch( 330 `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, 331 ); 332 const data = await res.json(); 333 assert.ok(data.cid); 334 }); 335 336 it('getRepoStatus returns did', async () => { 337 const res = await fetch( 338 `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, 339 ); 340 const data = await res.json(); 341 assert.ok(data.did); 342 }); 343 344 it('getRepo returns CAR', async () => { 345 const res = await fetch( 346 `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, 347 ); 348 const data = await res.arrayBuffer(); 349 assert.ok(data.byteLength > 100, 'Should return CAR data'); 350 }); 351 352 it('getRecord returns record CAR', async () => { 353 const res = await fetch( 354 `${BASE}/xrpc/com.atproto.sync.getRecord?did=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 355 ); 356 const data = await res.arrayBuffer(); 357 assert.ok(data.byteLength > 50); 358 }); 359 360 it('listRepos returns repos', async () => { 361 const res = await fetch(`${BASE}/xrpc/com.atproto.sync.listRepos`); 362 const data = await res.json(); 363 assert.ok(data.repos?.length > 0); 364 }); 365 }); 366 367 describe('Error handling', () => { 368 it('invalid password rejected (401)', async () => { 369 const { status } = await jsonPost( 370 '/xrpc/com.atproto.server.createSession', 371 { 372 identifier: DID, 373 password: 'wrong-password', 374 }, 375 ); 376 assert.strictEqual(status, 401); 377 }); 378 379 it('wrong repo rejected (403)', async () => { 380 const { status } = await jsonPost( 381 '/xrpc/com.atproto.repo.createRecord', 382 { 383 repo: 'did:plc:z72i7hdynmk6r22z27h6tvur', 384 collection: 'app.bsky.feed.post', 385 record: { text: 'x', createdAt: '2024-01-01T00:00:00Z' }, 386 }, 387 { Authorization: `Bearer ${token}` }, 388 ); 389 assert.strictEqual(status, 403); 390 }); 391 392 it('non-existent record errors', async () => { 393 const res = await fetch( 394 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=nonexistent`, 395 ); 396 assert.ok([400, 404].includes(res.status)); 397 }); 398 }); 399 400 describe('Blob endpoints', () => { 401 /** @type {string} */ 402 let blobCid = ''; 403 /** @type {string} */ 404 let blobPostRkey = ''; 405 406 // Create minimal PNG 407 const pngBytes = new Uint8Array([ 408 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 409 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 410 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 411 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 412 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 413 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 414 ]); 415 416 it('uploadBlob rejects without auth', async () => { 417 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 418 method: 'POST', 419 headers: { 'Content-Type': 'image/png' }, 420 body: pngBytes, 421 }); 422 assert.strictEqual(res.status, 401); 423 }); 424 425 it('uploadBlob returns CID', async () => { 426 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 427 method: 'POST', 428 headers: { 429 'Content-Type': 'image/png', 430 Authorization: `Bearer ${token}`, 431 }, 432 body: pngBytes, 433 }); 434 const data = await res.json(); 435 assert.ok(data.blob?.ref?.$link); 436 assert.strictEqual(data.blob?.mimeType, 'image/png'); 437 blobCid = data.blob.ref.$link; 438 }); 439 440 it('listBlobs includes uploaded blob', async () => { 441 const res = await fetch( 442 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 443 ); 444 const data = await res.json(); 445 assert.ok(data.cids?.includes(blobCid)); 446 }); 447 448 it('getBlob retrieves data', async () => { 449 const res = await fetch( 450 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, 451 ); 452 assert.ok(res.ok); 453 assert.strictEqual(res.headers.get('content-type'), 'image/png'); 454 assert.strictEqual(res.headers.get('x-content-type-options'), 'nosniff'); 455 }); 456 457 it('getBlob rejects wrong DID', async () => { 458 const res = await fetch( 459 `${BASE}/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=${blobCid}`, 460 ); 461 assert.strictEqual(res.status, 400); 462 }); 463 464 it('getBlob rejects invalid CID', async () => { 465 const res = await fetch( 466 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=invalid`, 467 ); 468 assert.strictEqual(res.status, 400); 469 }); 470 471 it('getBlob 404 for missing blob', async () => { 472 const res = await fetch( 473 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, 474 ); 475 assert.strictEqual(res.status, 404); 476 }); 477 478 it('createRecord with blob ref', async () => { 479 const { status, data } = await jsonPost( 480 '/xrpc/com.atproto.repo.createRecord', 481 { 482 repo: DID, 483 collection: 'app.bsky.feed.post', 484 record: { 485 text: 'post with image', 486 createdAt: new Date().toISOString(), 487 embed: { 488 $type: 'app.bsky.embed.images', 489 images: [ 490 { 491 image: { 492 $type: 'blob', 493 ref: { $link: blobCid }, 494 mimeType: 'image/png', 495 size: pngBytes.length, 496 }, 497 alt: 'test', 498 }, 499 ], 500 }, 501 }, 502 }, 503 { Authorization: `Bearer ${token}` }, 504 ); 505 assert.strictEqual(status, 200); 506 blobPostRkey = data.uri.split('/').pop(); 507 }); 508 509 it('blob persists after record creation', async () => { 510 const res = await fetch( 511 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 512 ); 513 const data = await res.json(); 514 assert.ok(data.cids?.includes(blobCid)); 515 }); 516 517 it('deleteRecord with blob cleans up', async () => { 518 const { status } = await jsonPost( 519 '/xrpc/com.atproto.repo.deleteRecord', 520 { repo: DID, collection: 'app.bsky.feed.post', rkey: blobPostRkey }, 521 { Authorization: `Bearer ${token}` }, 522 ); 523 assert.strictEqual(status, 200); 524 525 const res = await fetch( 526 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 527 ); 528 const data = await res.json(); 529 assert.strictEqual( 530 data.cids?.length, 531 0, 532 'Orphaned blob should be cleaned up', 533 ); 534 }); 535 }); 536 537 describe('OAuth endpoints', () => { 538 it('AS metadata', async () => { 539 const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); 540 const data = await res.json(); 541 assert.strictEqual(data.issuer, BASE); 542 assert.strictEqual( 543 data.authorization_endpoint, 544 `${BASE}/oauth/authorize`, 545 ); 546 assert.strictEqual(data.token_endpoint, `${BASE}/oauth/token`); 547 assert.strictEqual( 548 data.pushed_authorization_request_endpoint, 549 `${BASE}/oauth/par`, 550 ); 551 assert.strictEqual(data.revocation_endpoint, `${BASE}/oauth/revoke`); 552 assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`); 553 assert.deepStrictEqual(data.scopes_supported, ['atproto']); 554 assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']); 555 assert.strictEqual(data.require_pushed_authorization_requests, false); 556 assert.strictEqual(data.client_id_metadata_document_supported, true); 557 assert.deepStrictEqual(data.protected_resources, [BASE]); 558 }); 559 560 it('PR metadata', async () => { 561 const res = await fetch(`${BASE}/.well-known/oauth-protected-resource`); 562 const data = await res.json(); 563 assert.strictEqual(data.resource, BASE); 564 assert.deepStrictEqual(data.authorization_servers, [BASE]); 565 }); 566 567 it('JWKS endpoint', async () => { 568 const res = await fetch(`${BASE}/oauth/jwks`); 569 const data = await res.json(); 570 assert.ok(data.keys?.length > 0); 571 const key = data.keys[0]; 572 assert.strictEqual(key.kty, 'EC'); 573 assert.strictEqual(key.crv, 'P-256'); 574 assert.strictEqual(key.alg, 'ES256'); 575 assert.strictEqual(key.use, 'sig'); 576 assert.ok(key.x && key.y, 'Should have x,y coords'); 577 assert.ok(!key.d, 'Should not expose private key'); 578 }); 579 580 it('PAR rejects missing DPoP', async () => { 581 const { status, data } = await formPost('/oauth/par', { 582 client_id: 'http://localhost:3000', 583 redirect_uri: 'http://localhost:3000/callback', 584 response_type: 'code', 585 scope: 'atproto', 586 code_challenge: 'test', 587 code_challenge_method: 'S256', 588 }); 589 assert.strictEqual(status, 400); 590 assert.strictEqual(data.error, 'invalid_dpop_proof'); 591 }); 592 593 it('token rejects missing DPoP', async () => { 594 const { status, data } = await formPost('/oauth/token', { 595 grant_type: 'authorization_code', 596 code: 'fake', 597 client_id: 'http://localhost:3000', 598 }); 599 assert.strictEqual(status, 400); 600 assert.strictEqual(data.error, 'invalid_dpop_proof'); 601 }); 602 603 it('revoke returns 200 for invalid token', async () => { 604 const { status } = await formPost('/oauth/revoke', { 605 token: 'nonexistent', 606 client_id: 'http://localhost:3000', 607 }); 608 assert.strictEqual(status, 200); 609 }); 610 }); 611 612 describe('OAuth flow with DPoP', () => { 613 it('full PAR -> authorize -> token flow', async () => { 614 const dpop = await DpopClient.create(); 615 const clientId = 'http://localhost:3000'; 616 const redirectUri = 'http://localhost:3000/callback'; 617 const codeVerifier = randomBytes(32).toString('base64url'); 618 619 // Generate code_challenge from verifier (S256) 620 const challengeBuffer = await crypto.subtle.digest( 621 'SHA-256', 622 new TextEncoder().encode(codeVerifier), 623 ); 624 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 625 626 // Step 1: PAR request 627 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 628 const parRes = await fetch(`${BASE}/oauth/par`, { 629 method: 'POST', 630 headers: { 631 'Content-Type': 'application/x-www-form-urlencoded', 632 DPoP: parProof, 633 }, 634 body: new URLSearchParams({ 635 client_id: clientId, 636 redirect_uri: redirectUri, 637 response_type: 'code', 638 scope: 'atproto', 639 code_challenge: codeChallenge, 640 code_challenge_method: 'S256', 641 state: 'test-state', 642 login_hint: DID, 643 }).toString(), 644 }); 645 646 assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 647 const parData = await parRes.json(); 648 assert.ok(parData.request_uri, 'PAR should return request_uri'); 649 assert.ok(parData.expires_in > 0, 'PAR should return expires_in'); 650 651 // Step 2: Authorization (simulate user consent by POSTing to authorize) 652 const authRes = await fetch(`${BASE}/oauth/authorize`, { 653 method: 'POST', 654 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 655 body: new URLSearchParams({ 656 request_uri: parData.request_uri, 657 client_id: clientId, 658 password: PASSWORD, 659 }).toString(), 660 redirect: 'manual', 661 }); 662 663 assert.strictEqual(authRes.status, 302, 'Authorize should redirect'); 664 const location = authRes.headers.get('location'); 665 assert.ok(location, 'Should have Location header'); 666 667 const redirectUrl = new URL(location); 668 const authCode = redirectUrl.searchParams.get('code'); 669 assert.ok(authCode, 'Redirect should have code'); 670 assert.strictEqual(redirectUrl.searchParams.get('state'), 'test-state'); 671 assert.strictEqual(redirectUrl.searchParams.get('iss'), BASE); 672 673 // Step 3: Token exchange 674 const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 675 const tokenRes = await fetch(`${BASE}/oauth/token`, { 676 method: 'POST', 677 headers: { 678 'Content-Type': 'application/x-www-form-urlencoded', 679 DPoP: tokenProof, 680 }, 681 body: new URLSearchParams({ 682 grant_type: 'authorization_code', 683 code: authCode, 684 client_id: clientId, 685 redirect_uri: redirectUri, 686 code_verifier: codeVerifier, 687 }).toString(), 688 }); 689 690 assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 691 const tokenData = await tokenRes.json(); 692 assert.ok(tokenData.access_token, 'Should return access_token'); 693 assert.ok(tokenData.refresh_token, 'Should return refresh_token'); 694 assert.strictEqual(tokenData.token_type, 'DPoP'); 695 assert.strictEqual(tokenData.scope, 'atproto'); 696 assert.ok(tokenData.sub, 'Should return sub'); 697 698 // Step 4: Use access token with DPoP for protected endpoint 699 const resourceProof = await dpop.createProof( 700 'GET', 701 `${BASE}/xrpc/com.atproto.server.getSession`, 702 tokenData.access_token, 703 ); 704 const sessionRes = await fetch( 705 `${BASE}/xrpc/com.atproto.server.getSession`, 706 { 707 headers: { 708 Authorization: `DPoP ${tokenData.access_token}`, 709 DPoP: resourceProof, 710 }, 711 }, 712 ); 713 714 assert.strictEqual( 715 sessionRes.status, 716 200, 717 'Protected endpoint should work with DPoP token', 718 ); 719 const sessionData = await sessionRes.json(); 720 assert.ok(sessionData.did, 'Should return session data'); 721 722 // Step 5: Refresh token 723 const refreshProof = await dpop.createProof( 724 'POST', 725 `${BASE}/oauth/token`, 726 ); 727 const refreshRes = await fetch(`${BASE}/oauth/token`, { 728 method: 'POST', 729 headers: { 730 'Content-Type': 'application/x-www-form-urlencoded', 731 DPoP: refreshProof, 732 }, 733 body: new URLSearchParams({ 734 grant_type: 'refresh_token', 735 refresh_token: tokenData.refresh_token, 736 client_id: clientId, 737 }).toString(), 738 }); 739 740 assert.strictEqual(refreshRes.status, 200, 'Refresh should succeed'); 741 const refreshData = await refreshRes.json(); 742 assert.ok(refreshData.access_token, 'Should return new access_token'); 743 assert.ok(refreshData.refresh_token, 'Should return new refresh_token'); 744 745 // Step 6: Revoke token 746 const revokeRes = await fetch(`${BASE}/oauth/revoke`, { 747 method: 'POST', 748 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 749 body: new URLSearchParams({ 750 token: refreshData.refresh_token, 751 client_id: clientId, 752 }).toString(), 753 }); 754 assert.strictEqual(revokeRes.status, 200); 755 }); 756 757 it('DPoP key mismatch rejected', async () => { 758 const dpop1 = await DpopClient.create(); 759 const dpop2 = await DpopClient.create(); 760 const clientId = 'http://localhost:3000'; 761 const redirectUri = 'http://localhost:3000/callback'; 762 const codeVerifier = randomBytes(32).toString('base64url'); 763 const challengeBuffer = await crypto.subtle.digest( 764 'SHA-256', 765 new TextEncoder().encode(codeVerifier), 766 ); 767 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 768 769 // PAR with first key 770 const parProof = await dpop1.createProof('POST', `${BASE}/oauth/par`); 771 const parRes = await fetch(`${BASE}/oauth/par`, { 772 method: 'POST', 773 headers: { 774 'Content-Type': 'application/x-www-form-urlencoded', 775 DPoP: parProof, 776 }, 777 body: new URLSearchParams({ 778 client_id: clientId, 779 redirect_uri: redirectUri, 780 response_type: 'code', 781 scope: 'atproto', 782 code_challenge: codeChallenge, 783 code_challenge_method: 'S256', 784 login_hint: DID, 785 }).toString(), 786 }); 787 const parData = await parRes.json(); 788 789 // Authorize 790 const authRes = await fetch(`${BASE}/oauth/authorize`, { 791 method: 'POST', 792 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 793 body: new URLSearchParams({ 794 request_uri: parData.request_uri, 795 client_id: clientId, 796 password: PASSWORD, 797 }).toString(), 798 redirect: 'manual', 799 }); 800 const location = authRes.headers.get('location'); 801 const authCode = new URL(location).searchParams.get('code'); 802 803 // Token with DIFFERENT key should fail 804 const tokenProof = await dpop2.createProof('POST', `${BASE}/oauth/token`); 805 const tokenRes = await fetch(`${BASE}/oauth/token`, { 806 method: 'POST', 807 headers: { 808 'Content-Type': 'application/x-www-form-urlencoded', 809 DPoP: tokenProof, 810 }, 811 body: new URLSearchParams({ 812 grant_type: 'authorization_code', 813 code: authCode, 814 client_id: clientId, 815 redirect_uri: redirectUri, 816 code_verifier: codeVerifier, 817 }).toString(), 818 }); 819 820 assert.strictEqual(tokenRes.status, 400); 821 const tokenData = await tokenRes.json(); 822 assert.strictEqual(tokenData.error, 'invalid_dpop_proof'); 823 }); 824 825 it('fragment response_mode returns code in fragment', async () => { 826 const dpop = await DpopClient.create(); 827 const clientId = 'http://localhost:3000'; 828 const redirectUri = 'http://localhost:3000/callback'; 829 const codeVerifier = randomBytes(32).toString('base64url'); 830 const challengeBuffer = await crypto.subtle.digest( 831 'SHA-256', 832 new TextEncoder().encode(codeVerifier), 833 ); 834 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 835 836 // PAR with response_mode=fragment 837 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 838 const parRes = await fetch(`${BASE}/oauth/par`, { 839 method: 'POST', 840 headers: { 841 'Content-Type': 'application/x-www-form-urlencoded', 842 DPoP: parProof, 843 }, 844 body: new URLSearchParams({ 845 client_id: clientId, 846 redirect_uri: redirectUri, 847 response_type: 'code', 848 response_mode: 'fragment', 849 scope: 'atproto', 850 code_challenge: codeChallenge, 851 code_challenge_method: 'S256', 852 login_hint: DID, 853 }).toString(), 854 }); 855 const parData = await parRes.json(); 856 assert.ok(parData.request_uri); 857 858 // Authorize 859 const authRes = await fetch(`${BASE}/oauth/authorize`, { 860 method: 'POST', 861 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 862 body: new URLSearchParams({ 863 request_uri: parData.request_uri, 864 client_id: clientId, 865 password: PASSWORD, 866 }).toString(), 867 redirect: 'manual', 868 }); 869 870 assert.strictEqual(authRes.status, 302); 871 const location = authRes.headers.get('location'); 872 assert.ok(location); 873 // For fragment mode, code should be in hash fragment 874 assert.ok(location.includes('#'), 'Should use fragment'); 875 const url = new URL(location); 876 const fragment = new URLSearchParams(url.hash.slice(1)); 877 assert.ok(fragment.get('code'), 'Code should be in fragment'); 878 assert.ok(fragment.get('iss'), 'Issuer should be in fragment'); 879 }); 880 881 it('PKCE failure - wrong code_verifier rejected', async () => { 882 const dpop = await DpopClient.create(); 883 const clientId = 'http://localhost:3000'; 884 const redirectUri = 'http://localhost:3000/callback'; 885 const codeVerifier = randomBytes(32).toString('base64url'); 886 const wrongVerifier = randomBytes(32).toString('base64url'); 887 const challengeBuffer = await crypto.subtle.digest( 888 'SHA-256', 889 new TextEncoder().encode(codeVerifier), 890 ); 891 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 892 893 // PAR 894 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 895 const parRes = await fetch(`${BASE}/oauth/par`, { 896 method: 'POST', 897 headers: { 898 'Content-Type': 'application/x-www-form-urlencoded', 899 DPoP: parProof, 900 }, 901 body: new URLSearchParams({ 902 client_id: clientId, 903 redirect_uri: redirectUri, 904 response_type: 'code', 905 scope: 'atproto', 906 code_challenge: codeChallenge, 907 code_challenge_method: 'S256', 908 login_hint: DID, 909 }).toString(), 910 }); 911 const parData = await parRes.json(); 912 913 // Authorize 914 const authRes = await fetch(`${BASE}/oauth/authorize`, { 915 method: 'POST', 916 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 917 body: new URLSearchParams({ 918 request_uri: parData.request_uri, 919 client_id: clientId, 920 password: PASSWORD, 921 }).toString(), 922 redirect: 'manual', 923 }); 924 const location = authRes.headers.get('location'); 925 const authCode = new URL(location).searchParams.get('code'); 926 927 // Token with WRONG code_verifier should fail 928 const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 929 const tokenRes = await fetch(`${BASE}/oauth/token`, { 930 method: 'POST', 931 headers: { 932 'Content-Type': 'application/x-www-form-urlencoded', 933 DPoP: tokenProof, 934 }, 935 body: new URLSearchParams({ 936 grant_type: 'authorization_code', 937 code: authCode, 938 client_id: clientId, 939 redirect_uri: redirectUri, 940 code_verifier: wrongVerifier, 941 }).toString(), 942 }); 943 944 assert.strictEqual(tokenRes.status, 400); 945 const tokenData = await tokenRes.json(); 946 assert.strictEqual(tokenData.error, 'invalid_grant'); 947 assert.ok(tokenData.message?.includes('code_verifier')); 948 }); 949 950 it('redirect_uri mismatch rejected', async () => { 951 const dpop = await DpopClient.create(); 952 const clientId = 'http://localhost:3000'; 953 const codeVerifier = randomBytes(32).toString('base64url'); 954 const challengeBuffer = await crypto.subtle.digest( 955 'SHA-256', 956 new TextEncoder().encode(codeVerifier), 957 ); 958 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 959 960 // PAR with unregistered redirect_uri 961 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 962 const parRes = await fetch(`${BASE}/oauth/par`, { 963 method: 'POST', 964 headers: { 965 'Content-Type': 'application/x-www-form-urlencoded', 966 DPoP: parProof, 967 }, 968 body: new URLSearchParams({ 969 client_id: clientId, 970 redirect_uri: 'http://attacker.com/callback', 971 response_type: 'code', 972 scope: 'atproto', 973 code_challenge: codeChallenge, 974 code_challenge_method: 'S256', 975 login_hint: DID, 976 }).toString(), 977 }); 978 979 assert.strictEqual(parRes.status, 400); 980 const parData = await parRes.json(); 981 assert.strictEqual(parData.error, 'invalid_request'); 982 assert.ok(parData.message?.includes('redirect_uri')); 983 }); 984 985 it('DPoP jti replay rejected', async () => { 986 const dpop = await DpopClient.create(); 987 const clientId = 'http://localhost:3000'; 988 const redirectUri = 'http://localhost:3000/callback'; 989 const codeVerifier = randomBytes(32).toString('base64url'); 990 const challengeBuffer = await crypto.subtle.digest( 991 'SHA-256', 992 new TextEncoder().encode(codeVerifier), 993 ); 994 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 995 996 // Create a single DPoP proof 997 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 998 999 // First request should succeed 1000 const parRes1 = await fetch(`${BASE}/oauth/par`, { 1001 method: 'POST', 1002 headers: { 1003 'Content-Type': 'application/x-www-form-urlencoded', 1004 DPoP: parProof, 1005 }, 1006 body: new URLSearchParams({ 1007 client_id: clientId, 1008 redirect_uri: redirectUri, 1009 response_type: 'code', 1010 scope: 'atproto', 1011 code_challenge: codeChallenge, 1012 code_challenge_method: 'S256', 1013 login_hint: DID, 1014 }).toString(), 1015 }); 1016 assert.strictEqual(parRes1.status, 200); 1017 1018 // Second request with SAME proof should be rejected 1019 const parRes2 = await fetch(`${BASE}/oauth/par`, { 1020 method: 'POST', 1021 headers: { 1022 'Content-Type': 'application/x-www-form-urlencoded', 1023 DPoP: parProof, 1024 }, 1025 body: new URLSearchParams({ 1026 client_id: clientId, 1027 redirect_uri: redirectUri, 1028 response_type: 'code', 1029 scope: 'atproto', 1030 code_challenge: codeChallenge, 1031 code_challenge_method: 'S256', 1032 login_hint: DID, 1033 }).toString(), 1034 }); 1035 1036 assert.strictEqual(parRes2.status, 400); 1037 const data = await parRes2.json(); 1038 assert.strictEqual(data.error, 'invalid_dpop_proof'); 1039 assert.ok(data.message?.includes('replay')); 1040 }); 1041 }); 1042 1043 describe('Scope Enforcement', () => { 1044 it('createRecord denied with insufficient scope', async () => { 1045 // Get token that only allows creating likes, not posts 1046 const { accessToken, dpop } = await getOAuthTokenWithScope( 1047 'repo:app.bsky.feed.like?action=create', 1048 DID, 1049 PASSWORD, 1050 ); 1051 1052 const proof = await dpop.createProof( 1053 'POST', 1054 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1055 accessToken, 1056 ); 1057 1058 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1059 method: 'POST', 1060 headers: { 1061 'Content-Type': 'application/json', 1062 Authorization: `DPoP ${accessToken}`, 1063 DPoP: proof, 1064 }, 1065 body: JSON.stringify({ 1066 repo: DID, 1067 collection: 'app.bsky.feed.post', // Not allowed by scope 1068 record: { text: 'test', createdAt: new Date().toISOString() }, 1069 }), 1070 }); 1071 1072 assert.strictEqual(res.status, 403, 'Should reject with 403'); 1073 const body = await res.json(); 1074 assert.ok( 1075 body.message?.includes('Missing required scope'), 1076 'Error should mention missing scope', 1077 ); 1078 }); 1079 1080 it('createRecord allowed with matching scope', async () => { 1081 // Get token that allows creating posts 1082 const { accessToken, dpop } = await getOAuthTokenWithScope( 1083 'repo:app.bsky.feed.post?action=create', 1084 DID, 1085 PASSWORD, 1086 ); 1087 1088 const proof = await dpop.createProof( 1089 'POST', 1090 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1091 accessToken, 1092 ); 1093 1094 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1095 method: 'POST', 1096 headers: { 1097 'Content-Type': 'application/json', 1098 Authorization: `DPoP ${accessToken}`, 1099 DPoP: proof, 1100 }, 1101 body: JSON.stringify({ 1102 repo: DID, 1103 collection: 'app.bsky.feed.post', 1104 record: { text: 'scope test', createdAt: new Date().toISOString() }, 1105 }), 1106 }); 1107 1108 assert.strictEqual(res.status, 200, 'Should allow with correct scope'); 1109 const body = await res.json(); 1110 assert.ok(body.uri, 'Should return uri'); 1111 1112 // Note: We don't clean up here because our token only has create scope 1113 // The record will be cleaned up by subsequent tests with full-access tokens 1114 }); 1115 1116 it('createRecord allowed with wildcard collection scope', async () => { 1117 // Get token that allows creating any record type 1118 const { accessToken, dpop } = await getOAuthTokenWithScope( 1119 'repo:*?action=create', 1120 DID, 1121 PASSWORD, 1122 ); 1123 1124 const proof = await dpop.createProof( 1125 'POST', 1126 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1127 accessToken, 1128 ); 1129 1130 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1131 method: 'POST', 1132 headers: { 1133 'Content-Type': 'application/json', 1134 Authorization: `DPoP ${accessToken}`, 1135 DPoP: proof, 1136 }, 1137 body: JSON.stringify({ 1138 repo: DID, 1139 collection: 'app.bsky.feed.post', 1140 record: { 1141 text: 'wildcard scope test', 1142 createdAt: new Date().toISOString(), 1143 }, 1144 }), 1145 }); 1146 1147 assert.strictEqual( 1148 res.status, 1149 200, 1150 'Wildcard scope should allow any collection', 1151 ); 1152 }); 1153 1154 it('deleteRecord denied without delete scope', async () => { 1155 // Get token that only has create scope 1156 const { accessToken, dpop } = await getOAuthTokenWithScope( 1157 'repo:app.bsky.feed.post?action=create', 1158 DID, 1159 PASSWORD, 1160 ); 1161 1162 const proof = await dpop.createProof( 1163 'POST', 1164 `${BASE}/xrpc/com.atproto.repo.deleteRecord`, 1165 accessToken, 1166 ); 1167 1168 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { 1169 method: 'POST', 1170 headers: { 1171 'Content-Type': 'application/json', 1172 Authorization: `DPoP ${accessToken}`, 1173 DPoP: proof, 1174 }, 1175 body: JSON.stringify({ 1176 repo: DID, 1177 collection: 'app.bsky.feed.post', 1178 rkey: 'nonexistent', // Doesn't matter, should fail on scope first 1179 }), 1180 }); 1181 1182 assert.strictEqual( 1183 res.status, 1184 403, 1185 'Should reject delete without delete scope', 1186 ); 1187 }); 1188 1189 it('uploadBlob denied with mismatched MIME scope', async () => { 1190 // Get token that only allows image uploads 1191 const { accessToken, dpop } = await getOAuthTokenWithScope( 1192 'blob:image/*', 1193 DID, 1194 PASSWORD, 1195 ); 1196 1197 const proof = await dpop.createProof( 1198 'POST', 1199 `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1200 accessToken, 1201 ); 1202 1203 // Try to upload a video (not allowed by scope) 1204 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1205 method: 'POST', 1206 headers: { 1207 'Content-Type': 'video/mp4', 1208 Authorization: `DPoP ${accessToken}`, 1209 DPoP: proof, 1210 }, 1211 body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header 1212 }); 1213 1214 assert.strictEqual( 1215 res.status, 1216 403, 1217 'Should reject video upload with image-only scope', 1218 ); 1219 const body = await res.json(); 1220 assert.ok( 1221 body.message?.includes('Missing required scope'), 1222 'Error should mention missing scope', 1223 ); 1224 }); 1225 1226 it('uploadBlob allowed with matching MIME scope', async () => { 1227 // Get token that allows image uploads 1228 const { accessToken, dpop } = await getOAuthTokenWithScope( 1229 'blob:image/*', 1230 DID, 1231 PASSWORD, 1232 ); 1233 1234 const proof = await dpop.createProof( 1235 'POST', 1236 `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1237 accessToken, 1238 ); 1239 1240 // Minimal PNG 1241 const pngBytes = new Uint8Array([ 1242 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 1243 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 1244 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 1245 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 1246 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 1247 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 1248 ]); 1249 1250 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1251 method: 'POST', 1252 headers: { 1253 'Content-Type': 'image/png', 1254 Authorization: `DPoP ${accessToken}`, 1255 DPoP: proof, 1256 }, 1257 body: pngBytes, 1258 }); 1259 1260 assert.strictEqual( 1261 res.status, 1262 200, 1263 'Should allow image upload with image scope', 1264 ); 1265 }); 1266 1267 it('transition:generic grants full access', async () => { 1268 // Get token with transition:generic scope (full access) 1269 const { accessToken, dpop } = await getOAuthTokenWithScope( 1270 'transition:generic', 1271 DID, 1272 PASSWORD, 1273 ); 1274 1275 const proof = await dpop.createProof( 1276 'POST', 1277 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1278 accessToken, 1279 ); 1280 1281 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1282 method: 'POST', 1283 headers: { 1284 'Content-Type': 'application/json', 1285 Authorization: `DPoP ${accessToken}`, 1286 DPoP: proof, 1287 }, 1288 body: JSON.stringify({ 1289 repo: DID, 1290 collection: 'app.bsky.feed.post', 1291 record: { 1292 text: 'transition scope test', 1293 createdAt: new Date().toISOString(), 1294 }, 1295 }), 1296 }); 1297 1298 assert.strictEqual( 1299 res.status, 1300 200, 1301 'transition:generic should grant full access', 1302 ); 1303 }); 1304 }); 1305 1306 describe('Consent page display', () => { 1307 it('consent page shows permissions table for granular scopes', async () => { 1308 const dpop = await DpopClient.create(); 1309 const clientId = 'http://localhost:3000'; 1310 const redirectUri = 'http://localhost:3000/callback'; 1311 const codeVerifier = randomBytes(32).toString('base64url'); 1312 1313 const challengeBuffer = await crypto.subtle.digest( 1314 'SHA-256', 1315 new TextEncoder().encode(codeVerifier), 1316 ); 1317 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1318 1319 // PAR request with granular scopes 1320 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1321 const parRes = await fetch(`${BASE}/oauth/par`, { 1322 method: 'POST', 1323 headers: { 1324 'Content-Type': 'application/x-www-form-urlencoded', 1325 DPoP: parProof, 1326 }, 1327 body: new URLSearchParams({ 1328 client_id: clientId, 1329 redirect_uri: redirectUri, 1330 response_type: 'code', 1331 scope: 1332 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', 1333 code_challenge: codeChallenge, 1334 code_challenge_method: 'S256', 1335 state: 'test-state', 1336 login_hint: DID, 1337 }).toString(), 1338 }); 1339 1340 assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1341 const { request_uri } = await parRes.json(); 1342 1343 // GET the authorize page 1344 const authorizeRes = await fetch( 1345 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1346 ); 1347 1348 const html = await authorizeRes.text(); 1349 1350 // Verify permissions table is rendered 1351 assert.ok( 1352 html.includes('Repository permissions:'), 1353 'Should show repo permissions section', 1354 ); 1355 assert.ok( 1356 html.includes('app.bsky.feed.post'), 1357 'Should show collection name', 1358 ); 1359 assert.ok( 1360 html.includes('Upload permissions:'), 1361 'Should show upload permissions section', 1362 ); 1363 assert.ok(html.includes('image/*'), 'Should show blob MIME type'); 1364 }); 1365 1366 it('consent page shows identity message for atproto-only scope', async () => { 1367 const dpop = await DpopClient.create(); 1368 const clientId = 'http://localhost:3000'; 1369 const redirectUri = 'http://localhost:3000/callback'; 1370 const codeVerifier = randomBytes(32).toString('base64url'); 1371 1372 const challengeBuffer = await crypto.subtle.digest( 1373 'SHA-256', 1374 new TextEncoder().encode(codeVerifier), 1375 ); 1376 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1377 1378 // PAR request with atproto only (identity-only) 1379 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1380 const parRes = await fetch(`${BASE}/oauth/par`, { 1381 method: 'POST', 1382 headers: { 1383 'Content-Type': 'application/x-www-form-urlencoded', 1384 DPoP: parProof, 1385 }, 1386 body: new URLSearchParams({ 1387 client_id: clientId, 1388 redirect_uri: redirectUri, 1389 response_type: 'code', 1390 scope: 'atproto', 1391 code_challenge: codeChallenge, 1392 code_challenge_method: 'S256', 1393 state: 'test-state', 1394 login_hint: DID, 1395 }).toString(), 1396 }); 1397 1398 assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1399 const { request_uri } = await parRes.json(); 1400 1401 // GET the authorize page 1402 const authorizeRes = await fetch( 1403 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1404 ); 1405 1406 const html = await authorizeRes.text(); 1407 1408 // Verify identity-only message 1409 assert.ok( 1410 html.includes('wants to uniquely identify you'), 1411 'Should show identity-only message', 1412 ); 1413 assert.ok( 1414 !html.includes('Repository permissions:'), 1415 'Should NOT show permissions table', 1416 ); 1417 }); 1418 1419 it('consent page shows warning for transition:generic scope', async () => { 1420 const dpop = await DpopClient.create(); 1421 const clientId = 'http://localhost:3000'; 1422 const redirectUri = 'http://localhost:3000/callback'; 1423 const codeVerifier = randomBytes(32).toString('base64url'); 1424 1425 const challengeBuffer = await crypto.subtle.digest( 1426 'SHA-256', 1427 new TextEncoder().encode(codeVerifier), 1428 ); 1429 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1430 1431 // PAR request with transition:generic (full access) 1432 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1433 const parRes = await fetch(`${BASE}/oauth/par`, { 1434 method: 'POST', 1435 headers: { 1436 'Content-Type': 'application/x-www-form-urlencoded', 1437 DPoP: parProof, 1438 }, 1439 body: new URLSearchParams({ 1440 client_id: clientId, 1441 redirect_uri: redirectUri, 1442 response_type: 'code', 1443 scope: 'atproto transition:generic', 1444 code_challenge: codeChallenge, 1445 code_challenge_method: 'S256', 1446 state: 'test-state', 1447 login_hint: DID, 1448 }).toString(), 1449 }); 1450 1451 assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1452 const { request_uri } = await parRes.json(); 1453 1454 // GET the authorize page 1455 const authorizeRes = await fetch( 1456 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1457 ); 1458 1459 const html = await authorizeRes.text(); 1460 1461 // Verify warning banner 1462 assert.ok( 1463 html.includes('Full repository access requested'), 1464 'Should show full access warning', 1465 ); 1466 }); 1467 1468 it('supports direct authorization without PAR', async () => { 1469 const clientId = 'http://localhost:3000'; 1470 const redirectUri = 'http://localhost:3000/callback'; 1471 const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 1472 const challengeBuffer = await crypto.subtle.digest( 1473 'SHA-256', 1474 new TextEncoder().encode(codeVerifier), 1475 ); 1476 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1477 const state = 'test-direct-auth-state'; 1478 1479 // Step 1: GET authorize with direct parameters (no PAR) 1480 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1481 authorizeUrl.searchParams.set('client_id', clientId); 1482 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1483 authorizeUrl.searchParams.set('response_type', 'code'); 1484 authorizeUrl.searchParams.set('scope', 'atproto'); 1485 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1486 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1487 authorizeUrl.searchParams.set('state', state); 1488 authorizeUrl.searchParams.set('login_hint', DID); 1489 1490 const getRes = await fetch(authorizeUrl.toString()); 1491 assert.strictEqual( 1492 getRes.status, 1493 200, 1494 'Direct authorize GET should succeed', 1495 ); 1496 1497 const html = await getRes.text(); 1498 assert.ok(html.includes('Authorize'), 'Should show consent page'); 1499 assert.ok( 1500 html.includes('request_uri'), 1501 'Should include request_uri in form', 1502 ); 1503 }); 1504 1505 it('completes full direct authorization flow', async () => { 1506 const clientId = 'http://localhost:3000'; 1507 const redirectUri = 'http://localhost:3000/callback'; 1508 const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 1509 const challengeBuffer = await crypto.subtle.digest( 1510 'SHA-256', 1511 new TextEncoder().encode(codeVerifier), 1512 ); 1513 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1514 const state = 'test-direct-auth-state'; 1515 1516 // Step 1: GET authorize with direct parameters 1517 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1518 authorizeUrl.searchParams.set('client_id', clientId); 1519 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1520 authorizeUrl.searchParams.set('response_type', 'code'); 1521 authorizeUrl.searchParams.set('scope', 'atproto'); 1522 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1523 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1524 authorizeUrl.searchParams.set('state', state); 1525 authorizeUrl.searchParams.set('login_hint', DID); 1526 1527 const getRes = await fetch(authorizeUrl.toString()); 1528 assert.strictEqual(getRes.status, 200); 1529 const html = await getRes.text(); 1530 1531 // Extract request_uri from the form 1532 const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); 1533 assert.ok(requestUriMatch, 'Should have request_uri in form'); 1534 const requestUri = requestUriMatch[1]; 1535 1536 // Step 2: POST to authorize (user approval) 1537 const authRes = await fetch(`${BASE}/oauth/authorize`, { 1538 method: 'POST', 1539 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1540 body: new URLSearchParams({ 1541 request_uri: requestUri, 1542 client_id: clientId, 1543 password: PASSWORD, 1544 }).toString(), 1545 redirect: 'manual', 1546 }); 1547 1548 assert.strictEqual(authRes.status, 302, 'Should redirect after approval'); 1549 const location = authRes.headers.get('location'); 1550 assert.ok(location, 'Should have Location header'); 1551 const locationUrl = new URL(location); 1552 const code = locationUrl.searchParams.get('code'); 1553 assert.ok(code, 'Should have authorization code'); 1554 assert.strictEqual(locationUrl.searchParams.get('state'), state); 1555 1556 // Step 3: Exchange code for tokens 1557 const dpop = await DpopClient.create(); 1558 const dpopProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 1559 1560 const tokenRes = await fetch(`${BASE}/oauth/token`, { 1561 method: 'POST', 1562 headers: { 1563 'Content-Type': 'application/x-www-form-urlencoded', 1564 DPoP: dpopProof, 1565 }, 1566 body: new URLSearchParams({ 1567 grant_type: 'authorization_code', 1568 code, 1569 redirect_uri: redirectUri, 1570 client_id: clientId, 1571 code_verifier: codeVerifier, 1572 }).toString(), 1573 }); 1574 1575 assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 1576 const tokenData = await tokenRes.json(); 1577 assert.ok(tokenData.access_token, 'Should have access_token'); 1578 assert.strictEqual(tokenData.token_type, 'DPoP'); 1579 }); 1580 1581 it('consent page shows profile card when login_hint is provided', async () => { 1582 const clientId = 'http://localhost:3000'; 1583 const redirectUri = 'http://localhost:3000/callback'; 1584 const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!'; 1585 const challengeBuffer = await crypto.subtle.digest( 1586 'SHA-256', 1587 new TextEncoder().encode(codeVerifier), 1588 ); 1589 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1590 1591 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1592 authorizeUrl.searchParams.set('client_id', clientId); 1593 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1594 authorizeUrl.searchParams.set('response_type', 'code'); 1595 authorizeUrl.searchParams.set('scope', 'atproto'); 1596 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1597 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1598 authorizeUrl.searchParams.set('state', 'test-state'); 1599 authorizeUrl.searchParams.set('login_hint', 'test.handle.example'); 1600 1601 const res = await fetch(authorizeUrl.toString()); 1602 const html = await res.text(); 1603 1604 assert.ok( 1605 html.includes('profile-card'), 1606 'Should include profile card element', 1607 ); 1608 assert.ok( 1609 html.includes('@test.handle.example'), 1610 'Should show handle with @ prefix', 1611 ); 1612 assert.ok( 1613 html.includes('app.bsky.actor.getProfile'), 1614 'Should include profile fetch script', 1615 ); 1616 }); 1617 1618 it('consent page does not show profile card when login_hint is omitted', async () => { 1619 const clientId = 'http://localhost:3000'; 1620 const redirectUri = 'http://localhost:3000/callback'; 1621 const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!'; 1622 const challengeBuffer = await crypto.subtle.digest( 1623 'SHA-256', 1624 new TextEncoder().encode(codeVerifier), 1625 ); 1626 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1627 1628 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1629 authorizeUrl.searchParams.set('client_id', clientId); 1630 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1631 authorizeUrl.searchParams.set('response_type', 'code'); 1632 authorizeUrl.searchParams.set('scope', 'atproto'); 1633 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1634 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1635 authorizeUrl.searchParams.set('state', 'test-state'); 1636 // No login_hint parameter 1637 1638 const res = await fetch(authorizeUrl.toString()); 1639 const html = await res.text(); 1640 1641 // Check for the actual element (id="profile-card"), not the CSS class selector 1642 assert.ok( 1643 !html.includes('id="profile-card"'), 1644 'Should NOT include profile card element', 1645 ); 1646 assert.ok( 1647 !html.includes('app.bsky.actor.getProfile'), 1648 'Should NOT include profile fetch script', 1649 ); 1650 }); 1651 1652 it('consent page escapes dangerous characters in login_hint', async () => { 1653 const clientId = 'http://localhost:3000'; 1654 const redirectUri = 'http://localhost:3000/callback'; 1655 const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!'; 1656 const challengeBuffer = await crypto.subtle.digest( 1657 'SHA-256', 1658 new TextEncoder().encode(codeVerifier), 1659 ); 1660 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1661 1662 // Attempt XSS via login_hint with double quotes to break out of JSON.stringify 1663 const maliciousHint = 'user");alert("xss'; 1664 1665 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1666 authorizeUrl.searchParams.set('client_id', clientId); 1667 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1668 authorizeUrl.searchParams.set('response_type', 'code'); 1669 authorizeUrl.searchParams.set('scope', 'atproto'); 1670 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1671 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1672 authorizeUrl.searchParams.set('state', 'test-state'); 1673 authorizeUrl.searchParams.set('login_hint', maliciousHint); 1674 1675 const res = await fetch(authorizeUrl.toString()); 1676 const html = await res.text(); 1677 1678 // JSON.stringify escapes double quotes, so the payload should be escaped 1679 // The raw ");alert(" should NOT appear - it should be escaped as \");alert(\" 1680 assert.ok( 1681 !html.includes('");alert("'), 1682 'Should escape double quotes to prevent XSS breakout', 1683 ); 1684 // Verify the escaped version is present (backslash before the quote) 1685 assert.ok( 1686 html.includes('\\"'), 1687 'Should contain escaped characters from JSON.stringify', 1688 ); 1689 }); 1690 }); 1691 1692 describe('Foreign DID proxying', () => { 1693 it('proxies to AppView when atproto-proxy header present', async () => { 1694 // Use a known public DID (bsky.app official account) 1695 // We expect 200 (record exists) or 400 (record deleted/not found) from AppView 1696 // A 502 would indicate proxy failure, 404 would indicate local handling 1697 const res = await fetch( 1698 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1699 { 1700 headers: { 1701 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', 1702 }, 1703 }, 1704 ); 1705 // AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502 1706 assert.ok( 1707 res.status === 200 || res.status === 400, 1708 `Expected 200 or 400 from AppView, got ${res.status}`, 1709 ); 1710 // Verify we got a JSON response (not an error page) 1711 const contentType = res.headers.get('content-type'); 1712 assert.ok( 1713 contentType?.includes('application/json'), 1714 'Should return JSON', 1715 ); 1716 }); 1717 1718 it('handles foreign repo locally without header (returns not found)', async () => { 1719 // Foreign DID without atproto-proxy header is handled locally 1720 // This returns an error since the foreign DID doesn't exist on this PDS 1721 const res = await fetch( 1722 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1723 ); 1724 // Local PDS returns 404 for non-existent record/DID 1725 assert.strictEqual(res.status, 404); 1726 }); 1727 1728 it('returns error for unknown proxy service', async () => { 1729 const res = await fetch( 1730 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1731 { 1732 headers: { 1733 'atproto-proxy': 'did:web:unknown.service#unknown', 1734 }, 1735 }, 1736 ); 1737 assert.strictEqual(res.status, 400); 1738 const data = await res.json(); 1739 assert.ok(data.message.includes('Unknown proxy service')); 1740 }); 1741 1742 it('returns error for malformed atproto-proxy header', async () => { 1743 // Header without fragment separator 1744 const res1 = await fetch( 1745 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1746 { 1747 headers: { 1748 'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId 1749 }, 1750 }, 1751 ); 1752 assert.strictEqual(res1.status, 400); 1753 const data1 = await res1.json(); 1754 assert.ok(data1.message.includes('Malformed atproto-proxy header')); 1755 1756 // Header with only fragment 1757 const res2 = await fetch( 1758 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1759 { 1760 headers: { 1761 'atproto-proxy': '#bsky_appview', // missing DID 1762 }, 1763 }, 1764 ); 1765 assert.strictEqual(res2.status, 400); 1766 const data2 = await res2.json(); 1767 assert.ok(data2.message.includes('Malformed atproto-proxy header')); 1768 }); 1769 1770 it('returns local record for local DID without proxy header', async () => { 1771 // Create a record first 1772 const { data: created } = await jsonPost( 1773 '/xrpc/com.atproto.repo.createRecord', 1774 { 1775 repo: DID, 1776 collection: 'app.bsky.feed.post', 1777 record: { 1778 $type: 'app.bsky.feed.post', 1779 text: 'Test post for local DID test', 1780 createdAt: new Date().toISOString(), 1781 }, 1782 }, 1783 { Authorization: `Bearer ${token}` }, 1784 ); 1785 1786 // Fetch without proxy header - should get local record 1787 const rkey = created.uri.split('/').pop(); 1788 const res = await fetch( 1789 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, 1790 ); 1791 assert.strictEqual(res.status, 200); 1792 const data = await res.json(); 1793 assert.ok(data.value.text.includes('Test post for local DID test')); 1794 1795 // Cleanup - verify success to ensure test isolation 1796 const { status: cleanupStatus } = await jsonPost( 1797 '/xrpc/com.atproto.repo.deleteRecord', 1798 { repo: DID, collection: 'app.bsky.feed.post', rkey }, 1799 { Authorization: `Bearer ${token}` }, 1800 ); 1801 assert.strictEqual(cleanupStatus, 200, 'Cleanup should succeed'); 1802 }); 1803 1804 it('describeRepo handles foreign DID locally', async () => { 1805 // Without proxy header, foreign DID is handled locally (returns error) 1806 const res = await fetch( 1807 `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, 1808 ); 1809 // Local PDS returns 404 for non-existent DID 1810 assert.strictEqual(res.status, 404); 1811 }); 1812 1813 it('listRecords handles foreign DID locally', async () => { 1814 // Without proxy header, foreign DID is handled locally 1815 // listRecords returns 200 with empty records for non-existent collection 1816 const res = await fetch( 1817 `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, 1818 ); 1819 // Local PDS returns 200 with empty records (or 404 for completely unknown DID) 1820 assert.ok( 1821 res.status === 200 || res.status === 404, 1822 `Expected 200 or 404, got ${res.status}`, 1823 ); 1824 }); 1825 }); 1826 1827 describe('Cleanup', () => { 1828 it('deleteRecord (cleanup)', async () => { 1829 const { status } = await jsonPost( 1830 '/xrpc/com.atproto.repo.deleteRecord', 1831 { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey }, 1832 { Authorization: `Bearer ${token}` }, 1833 ); 1834 assert.strictEqual(status, 200); 1835 }); 1836 }); 1837});