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