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