/** * E2E tests for PDS - runs against local wrangler dev * Uses Node's built-in test runner and fetch */ import assert from 'node:assert'; import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { after, before, describe, it } from 'node:test'; import { DpopClient } from './helpers/dpop.js'; import { getOAuthTokenWithScope } from './helpers/oauth.js'; const BASE = 'http://localhost:8787'; const DID = `did:plc:test${randomBytes(8).toString('hex')}`; const PASSWORD = 'test-password'; /** @type {import('node:child_process').ChildProcess|null} */ let wrangler = null; /** @type {string} */ let token = ''; /** @type {string} */ let refreshToken = ''; /** @type {string} */ let testRkey = ''; /** * Wait for server to be ready */ async function waitForServer(maxAttempts = 30) { for (let i = 0; i < maxAttempts; i++) { try { const res = await fetch(`${BASE}/`); if (res.ok) return; } catch { // Server not ready yet } await new Promise((r) => setTimeout(r, 500)); } throw new Error('Server failed to start'); } /** * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) */ async function jsonPost(path, body, headers = {}) { for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); // Retry on 5xx errors (wrangler dev flakiness) if (res.status >= 500 && attempt < 2) { await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); continue; } return { status: res.status, data: res.ok ? await res.json() : null }; } } /** * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) */ async function formPost(path, params, headers = {}) { for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers, }, body: new URLSearchParams(params).toString(), }); // Retry on 5xx errors (wrangler dev flakiness) if (res.status >= 500 && attempt < 2) { await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); continue; } const text = await res.text(); let data = null; try { data = JSON.parse(text); } catch { data = text; } return { status: res.status, data }; } } describe('E2E Tests', () => { before(async () => { // Start wrangler wrangler = spawn( 'npx', ['wrangler', 'dev', '--port', '8787', '--persist-to', '.wrangler/state'], { stdio: 'pipe', cwd: process.cwd(), }, ); await waitForServer(); // Initialize PDS const privKey = randomBytes(32).toString('hex'); const res = await fetch(`${BASE}/init?did=${DID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ did: DID, privateKey: privKey, handle: 'test.local', }), }); assert.ok(res.ok, 'PDS initialization failed'); }); after(() => { if (wrangler) { wrangler.kill(); } }); describe('Server endpoints', () => { it('root returns ASCII art', async () => { const res = await fetch(`${BASE}/`); const text = await res.text(); assert.ok(text.includes('PDS'), 'Root should contain PDS'); }); it('describeServer returns DID', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); const data = await res.json(); assert.ok(data.did, 'describeServer should return did'); }); it('resolveHandle returns DID', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.identity.resolveHandle?handle=test.local`, ); const data = await res.json(); assert.ok(data.did, 'resolveHandle should return did'); }); }); describe('Authentication', () => { it('createSession returns tokens', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: PASSWORD, }, ); assert.strictEqual(status, 200); assert.ok(data.accessJwt, 'Should return accessJwt'); assert.ok(data.refreshJwt, 'Should return refreshJwt'); token = data.accessJwt; refreshToken = data.refreshJwt; }); it('getSession with valid token', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.server.getSession`, { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); assert.ok(data.did, 'getSession should return did'); }); it('refreshSession returns new tokens', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', headers: { Authorization: `Bearer ${refreshToken}` }, }, ); const data = await res.json(); assert.ok(data.accessJwt, 'Should return new accessJwt'); assert.ok(data.refreshJwt, 'Should return new refreshJwt'); token = data.accessJwt; // Use new token }); it('refreshSession rejects access token', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }, ); assert.strictEqual(res.status, 400); }); it('refreshSession rejects missing auth', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', }, ); assert.strictEqual(res.status, 401); }); it('createRecord rejects without auth', async () => { const { status } = await jsonPost('/xrpc/com.atproto.repo.createRecord', { repo: 'x', collection: 'x', record: {}, }); assert.strictEqual(status, 401); }); it('getPreferences works', async () => { const res = await fetch(`${BASE}/xrpc/app.bsky.actor.getPreferences`, { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); assert.ok(data.preferences, 'Should return preferences'); }); it('putPreferences works', async () => { const { status } = await jsonPost( '/xrpc/app.bsky.actor.putPreferences', { preferences: [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2' }] }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); }); }); describe('Record operations', () => { it('createRecord with auth', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'test', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); assert.ok(data.uri, 'Should return uri'); testRkey = data.uri.split('/').pop(); }); it('getRecord returns record', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, ); const data = await res.json(); assert.ok(data.value?.text, 'Should return record value'); }); it('putRecord updates record', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.putRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey, record: { text: 'updated', createdAt: new Date().toISOString() }, }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); assert.ok(data.uri); }); it('listRecords returns records', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=app.bsky.feed.post`, ); const data = await res.json(); assert.ok(data.records?.length > 0, 'Should return records'); }); it('describeRepo returns did', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, ); const data = await res.json(); assert.ok(data.did); }); it('applyWrites create', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.feed.post', rkey: 'applytest', value: { text: 'batch', createdAt: new Date().toISOString() }, }, ], }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); assert.ok(data.results); }); it('applyWrites delete', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.applyWrites', { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#delete', collection: 'app.bsky.feed.post', rkey: 'applytest', }, ], }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); assert.ok(data.results); }); }); describe('Sync endpoints', () => { it('getLatestCommit returns cid', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, ); const data = await res.json(); assert.ok(data.cid); }); it('getRepoStatus returns did', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, ); const data = await res.json(); assert.ok(data.did); }); it('getRepo returns CAR', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, ); const data = await res.arrayBuffer(); assert.ok(data.byteLength > 100, 'Should return CAR data'); }); it('getRecord returns record CAR', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getRecord?did=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, ); const data = await res.arrayBuffer(); assert.ok(data.byteLength > 50); }); it('listRepos returns repos', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.sync.listRepos`); const data = await res.json(); assert.ok(data.repos?.length > 0); }); }); describe('Error handling', () => { it('invalid password rejected (401)', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.server.createSession', { identifier: DID, password: 'wrong-password', }, ); assert.strictEqual(status, 401); }); it('wrong repo rejected (403)', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: 'did:plc:z72i7hdynmk6r22z27h6tvur', collection: 'app.bsky.feed.post', record: { text: 'x', createdAt: '2024-01-01T00:00:00Z' }, }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 403); }); it('non-existent record errors', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=nonexistent`, ); assert.ok([400, 404].includes(res.status)); }); }); describe('Blob endpoints', () => { /** @type {string} */ let blobCid = ''; /** @type {string} */ let blobPostRkey = ''; // Create minimal PNG const pngBytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); it('uploadBlob rejects without auth', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: pngBytes, }); assert.strictEqual(res.status, 401); }); it('uploadBlob returns CID', async () => { const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'image/png', Authorization: `Bearer ${token}`, }, body: pngBytes, }); const data = await res.json(); assert.ok(data.blob?.ref?.$link); assert.strictEqual(data.blob?.mimeType, 'image/png'); blobCid = data.blob.ref.$link; }); it('listBlobs includes uploaded blob', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, ); const data = await res.json(); assert.ok(data.cids?.includes(blobCid)); }); it('getBlob retrieves data', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, ); assert.ok(res.ok); assert.strictEqual(res.headers.get('content-type'), 'image/png'); assert.strictEqual(res.headers.get('x-content-type-options'), 'nosniff'); }); it('getBlob rejects wrong DID', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=${blobCid}`, ); assert.strictEqual(res.status, 400); }); it('getBlob rejects invalid CID', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=invalid`, ); assert.strictEqual(res.status, 400); }); it('getBlob 404 for missing blob', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, ); assert.strictEqual(res.status, 404); }); it('createRecord with blob ref', async () => { const { status, data } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { text: 'post with image', createdAt: new Date().toISOString(), embed: { $type: 'app.bsky.embed.images', images: [ { image: { $type: 'blob', ref: { $link: blobCid }, mimeType: 'image/png', size: pngBytes.length, }, alt: 'test', }, ], }, }, }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); blobPostRkey = data.uri.split('/').pop(); }); it('blob persists after record creation', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, ); const data = await res.json(); assert.ok(data.cids?.includes(blobCid)); }); it('deleteRecord with blob cleans up', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: blobPostRkey }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); const res = await fetch( `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, ); const data = await res.json(); assert.strictEqual( data.cids?.length, 0, 'Orphaned blob should be cleaned up', ); }); }); describe('OAuth endpoints', () => { it('AS metadata', async () => { const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); const data = await res.json(); assert.strictEqual(data.issuer, BASE); assert.strictEqual( data.authorization_endpoint, `${BASE}/oauth/authorize`, ); assert.strictEqual(data.token_endpoint, `${BASE}/oauth/token`); assert.strictEqual( data.pushed_authorization_request_endpoint, `${BASE}/oauth/par`, ); assert.strictEqual(data.revocation_endpoint, `${BASE}/oauth/revoke`); assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`); assert.deepStrictEqual(data.scopes_supported, ['atproto']); assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']); assert.strictEqual(data.require_pushed_authorization_requests, false); assert.strictEqual(data.client_id_metadata_document_supported, true); assert.deepStrictEqual(data.protected_resources, [BASE]); }); it('PR metadata', async () => { const res = await fetch(`${BASE}/.well-known/oauth-protected-resource`); const data = await res.json(); assert.strictEqual(data.resource, BASE); assert.deepStrictEqual(data.authorization_servers, [BASE]); }); it('JWKS endpoint', async () => { const res = await fetch(`${BASE}/oauth/jwks`); const data = await res.json(); assert.ok(data.keys?.length > 0); const key = data.keys[0]; assert.strictEqual(key.kty, 'EC'); assert.strictEqual(key.crv, 'P-256'); assert.strictEqual(key.alg, 'ES256'); assert.strictEqual(key.use, 'sig'); assert.ok(key.x && key.y, 'Should have x,y coords'); assert.ok(!key.d, 'Should not expose private key'); }); it('PAR rejects missing DPoP', async () => { const { status, data } = await formPost('/oauth/par', { client_id: 'http://localhost:3000', redirect_uri: 'http://localhost:3000/callback', response_type: 'code', scope: 'atproto', code_challenge: 'test', code_challenge_method: 'S256', }); assert.strictEqual(status, 400); assert.strictEqual(data.error, 'invalid_dpop_proof'); }); it('token rejects missing DPoP', async () => { const { status, data } = await formPost('/oauth/token', { grant_type: 'authorization_code', code: 'fake', client_id: 'http://localhost:3000', }); assert.strictEqual(status, 400); assert.strictEqual(data.error, 'invalid_dpop_proof'); }); it('revoke returns 200 for invalid token', async () => { const { status } = await formPost('/oauth/revoke', { token: 'nonexistent', client_id: 'http://localhost:3000', }); assert.strictEqual(status, 200); }); }); describe('OAuth flow with DPoP', () => { it('full PAR -> authorize -> token flow', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); // Generate code_challenge from verifier (S256) const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // Step 1: PAR request const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); assert.strictEqual(parRes.status, 200, 'PAR should succeed'); const parData = await parRes.json(); assert.ok(parData.request_uri, 'PAR should return request_uri'); assert.ok(parData.expires_in > 0, 'PAR should return expires_in'); // Step 2: Authorization (simulate user consent by POSTing to authorize) const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); assert.strictEqual(authRes.status, 302, 'Authorize should redirect'); const location = authRes.headers.get('location'); assert.ok(location, 'Should have Location header'); const redirectUrl = new URL(location); const authCode = redirectUrl.searchParams.get('code'); assert.ok(authCode, 'Redirect should have code'); assert.strictEqual(redirectUrl.searchParams.get('state'), 'test-state'); assert.strictEqual(redirectUrl.searchParams.get('iss'), BASE); // Step 3: Token exchange const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: tokenProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, client_id: clientId, redirect_uri: redirectUri, code_verifier: codeVerifier, }).toString(), }); assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); const tokenData = await tokenRes.json(); assert.ok(tokenData.access_token, 'Should return access_token'); assert.ok(tokenData.refresh_token, 'Should return refresh_token'); assert.strictEqual(tokenData.token_type, 'DPoP'); assert.strictEqual(tokenData.scope, 'atproto'); assert.ok(tokenData.sub, 'Should return sub'); // Step 4: Use access token with DPoP for protected endpoint const resourceProof = await dpop.createProof( 'GET', `${BASE}/xrpc/com.atproto.server.getSession`, tokenData.access_token, ); const sessionRes = await fetch( `${BASE}/xrpc/com.atproto.server.getSession`, { headers: { Authorization: `DPoP ${tokenData.access_token}`, DPoP: resourceProof, }, }, ); assert.strictEqual( sessionRes.status, 200, 'Protected endpoint should work with DPoP token', ); const sessionData = await sessionRes.json(); assert.ok(sessionData.did, 'Should return session data'); // Step 5: Refresh token const refreshProof = await dpop.createProof( 'POST', `${BASE}/oauth/token`, ); const refreshRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: refreshProof, }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokenData.refresh_token, client_id: clientId, }).toString(), }); assert.strictEqual(refreshRes.status, 200, 'Refresh should succeed'); const refreshData = await refreshRes.json(); assert.ok(refreshData.access_token, 'Should return new access_token'); assert.ok(refreshData.refresh_token, 'Should return new refresh_token'); // Step 6: Revoke token const revokeRes = await fetch(`${BASE}/oauth/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: refreshData.refresh_token, client_id: clientId, }).toString(), }); assert.strictEqual(revokeRes.status, 200); }); it('DPoP key mismatch rejected', async () => { const dpop1 = await DpopClient.create(); const dpop2 = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR with first key const parProof = await dpop1.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); const parData = await parRes.json(); // Authorize const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); const location = authRes.headers.get('location'); const authCode = new URL(location).searchParams.get('code'); // Token with DIFFERENT key should fail const tokenProof = await dpop2.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: tokenProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, client_id: clientId, redirect_uri: redirectUri, code_verifier: codeVerifier, }).toString(), }); assert.strictEqual(tokenRes.status, 400); const tokenData = await tokenRes.json(); assert.strictEqual(tokenData.error, 'invalid_dpop_proof'); }); it('fragment response_mode returns code in fragment', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR with response_mode=fragment const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', response_mode: 'fragment', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); const parData = await parRes.json(); assert.ok(parData.request_uri); // Authorize const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); assert.strictEqual(authRes.status, 302); const location = authRes.headers.get('location'); assert.ok(location); // For fragment mode, code should be in hash fragment assert.ok(location.includes('#'), 'Should use fragment'); const url = new URL(location); const fragment = new URLSearchParams(url.hash.slice(1)); assert.ok(fragment.get('code'), 'Code should be in fragment'); assert.ok(fragment.get('iss'), 'Issuer should be in fragment'); }); it('PKCE failure - wrong code_verifier rejected', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const wrongVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); const parData = await parRes.json(); // Authorize const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: parData.request_uri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); const location = authRes.headers.get('location'); const authCode = new URL(location).searchParams.get('code'); // Token with WRONG code_verifier should fail const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: tokenProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authCode, client_id: clientId, redirect_uri: redirectUri, code_verifier: wrongVerifier, }).toString(), }); assert.strictEqual(tokenRes.status, 400); const tokenData = await tokenRes.json(); assert.strictEqual(tokenData.error, 'invalid_grant'); assert.ok(tokenData.message?.includes('code_verifier')); }); it('redirect_uri mismatch rejected', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR with unregistered redirect_uri const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: 'http://attacker.com/callback', response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); assert.strictEqual(parRes.status, 400); const parData = await parRes.json(); assert.strictEqual(parData.error, 'invalid_request'); assert.ok(parData.message?.includes('redirect_uri')); }); it('DPoP jti replay rejected', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // Create a single DPoP proof const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); // First request should succeed const parRes1 = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); assert.strictEqual(parRes1.status, 200); // Second request with SAME proof should be rejected const parRes2 = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', login_hint: DID, }).toString(), }); assert.strictEqual(parRes2.status, 400); const data = await parRes2.json(); assert.strictEqual(data.error, 'invalid_dpop_proof'); assert.ok(data.message?.includes('replay')); }); }); describe('Scope Enforcement', () => { it('createRecord denied with insufficient scope', async () => { // Get token that only allows creating likes, not posts const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.like?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', // Not allowed by scope record: { text: 'test', createdAt: new Date().toISOString() }, }), }); assert.strictEqual(res.status, 403, 'Should reject with 403'); const body = await res.json(); assert.ok( body.message?.includes('Missing required scope'), 'Error should mention missing scope', ); }); it('createRecord allowed with matching scope', async () => { // Get token that allows creating posts const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.post?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', record: { text: 'scope test', createdAt: new Date().toISOString() }, }), }); assert.strictEqual(res.status, 200, 'Should allow with correct scope'); const body = await res.json(); assert.ok(body.uri, 'Should return uri'); // Note: We don't clean up here because our token only has create scope // The record will be cleaned up by subsequent tests with full-access tokens }); it('createRecord allowed with wildcard collection scope', async () => { // Get token that allows creating any record type const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:*?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', record: { text: 'wildcard scope test', createdAt: new Date().toISOString(), }, }), }); assert.strictEqual( res.status, 200, 'Wildcard scope should allow any collection', ); }); it('deleteRecord denied without delete scope', async () => { // Get token that only has create scope const { accessToken, dpop } = await getOAuthTokenWithScope( 'repo:app.bsky.feed.post?action=create', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.deleteRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', rkey: 'nonexistent', // Doesn't matter, should fail on scope first }), }); assert.strictEqual( res.status, 403, 'Should reject delete without delete scope', ); }); it('uploadBlob denied with mismatched MIME scope', async () => { // Get token that only allows image uploads const { accessToken, dpop } = await getOAuthTokenWithScope( 'blob:image/*', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.uploadBlob`, accessToken, ); // Try to upload a video (not allowed by scope) const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'video/mp4', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header }); assert.strictEqual( res.status, 403, 'Should reject video upload with image-only scope', ); const body = await res.json(); assert.ok( body.message?.includes('Missing required scope'), 'Error should mention missing scope', ); }); it('uploadBlob allowed with matching MIME scope', async () => { // Get token that allows image uploads const { accessToken, dpop } = await getOAuthTokenWithScope( 'blob:image/*', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.uploadBlob`, accessToken, ); // Minimal PNG const pngBytes = new Uint8Array([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'Content-Type': 'image/png', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: pngBytes, }); assert.strictEqual( res.status, 200, 'Should allow image upload with image scope', ); }); it('transition:generic grants full access', async () => { // Get token with transition:generic scope (full access) const { accessToken, dpop } = await getOAuthTokenWithScope( 'transition:generic', DID, PASSWORD, ); const proof = await dpop.createProof( 'POST', `${BASE}/xrpc/com.atproto.repo.createRecord`, accessToken, ); const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `DPoP ${accessToken}`, DPoP: proof, }, body: JSON.stringify({ repo: DID, collection: 'app.bsky.feed.post', record: { text: 'transition scope test', createdAt: new Date().toISOString(), }, }), }); assert.strictEqual( res.status, 200, 'transition:generic should grant full access', ); }); }); describe('Consent page display', () => { it('consent page shows permissions table for granular scopes', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR request with granular scopes const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); assert.strictEqual(parRes.status, 200, 'PAR should succeed'); const { request_uri } = await parRes.json(); // GET the authorize page const authorizeRes = await fetch( `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, ); const html = await authorizeRes.text(); // Verify permissions table is rendered assert.ok( html.includes('Repository permissions:'), 'Should show repo permissions section', ); assert.ok( html.includes('app.bsky.feed.post'), 'Should show collection name', ); assert.ok( html.includes('Upload permissions:'), 'Should show upload permissions section', ); assert.ok(html.includes('image/*'), 'Should show blob MIME type'); }); it('consent page shows identity message for atproto-only scope', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR request with atproto only (identity-only) const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); assert.strictEqual(parRes.status, 200, 'PAR should succeed'); const { request_uri } = await parRes.json(); // GET the authorize page const authorizeRes = await fetch( `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, ); const html = await authorizeRes.text(); // Verify identity-only message assert.ok( html.includes('wants to uniquely identify you'), 'Should show identity-only message', ); assert.ok( !html.includes('Repository permissions:'), 'Should NOT show permissions table', ); }); it('consent page shows warning for transition:generic scope', async () => { const dpop = await DpopClient.create(); const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = randomBytes(32).toString('base64url'); const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // PAR request with transition:generic (full access) const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); const parRes = await fetch(`${BASE}/oauth/par`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: parProof, }, body: new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto transition:generic', code_challenge: codeChallenge, code_challenge_method: 'S256', state: 'test-state', login_hint: DID, }).toString(), }); assert.strictEqual(parRes.status, 200, 'PAR should succeed'); const { request_uri } = await parRes.json(); // GET the authorize page const authorizeRes = await fetch( `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, ); const html = await authorizeRes.text(); // Verify warning banner assert.ok( html.includes('Full repository access requested'), 'Should show full access warning', ); }); it('supports direct authorization without PAR', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const state = 'test-direct-auth-state'; // Step 1: GET authorize with direct parameters (no PAR) const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', state); authorizeUrl.searchParams.set('login_hint', DID); const getRes = await fetch(authorizeUrl.toString()); assert.strictEqual( getRes.status, 200, 'Direct authorize GET should succeed', ); const html = await getRes.text(); assert.ok(html.includes('Authorize'), 'Should show consent page'); assert.ok( html.includes('request_uri'), 'Should include request_uri in form', ); }); it('completes full direct authorization flow', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const state = 'test-direct-auth-state'; // Step 1: GET authorize with direct parameters const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', state); authorizeUrl.searchParams.set('login_hint', DID); const getRes = await fetch(authorizeUrl.toString()); assert.strictEqual(getRes.status, 200); const html = await getRes.text(); // Extract request_uri from the form const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); assert.ok(requestUriMatch, 'Should have request_uri in form'); const requestUri = requestUriMatch[1]; // Step 2: POST to authorize (user approval) const authRes = await fetch(`${BASE}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ request_uri: requestUri, client_id: clientId, password: PASSWORD, }).toString(), redirect: 'manual', }); assert.strictEqual(authRes.status, 302, 'Should redirect after approval'); const location = authRes.headers.get('location'); assert.ok(location, 'Should have Location header'); const locationUrl = new URL(location); const code = locationUrl.searchParams.get('code'); assert.ok(code, 'Should have authorization code'); assert.strictEqual(locationUrl.searchParams.get('state'), state); // Step 3: Exchange code for tokens const dpop = await DpopClient.create(); const dpopProof = await dpop.createProof('POST', `${BASE}/oauth/token`); const tokenRes = await fetch(`${BASE}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof, }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: codeVerifier, }).toString(), }); assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); const tokenData = await tokenRes.json(); assert.ok(tokenData.access_token, 'Should have access_token'); assert.strictEqual(tokenData.token_type, 'DPoP'); }); it('consent page shows profile card when login_hint is provided', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', 'test-state'); authorizeUrl.searchParams.set('login_hint', 'test.handle.example'); const res = await fetch(authorizeUrl.toString()); const html = await res.text(); assert.ok( html.includes('profile-card'), 'Should include profile card element', ); assert.ok( html.includes('@test.handle.example'), 'Should show handle with @ prefix', ); assert.ok( html.includes('app.bsky.actor.getProfile'), 'Should include profile fetch script', ); }); it('consent page does not show profile card when login_hint is omitted', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', 'test-state'); // No login_hint parameter const res = await fetch(authorizeUrl.toString()); const html = await res.text(); // Check for the actual element (id="profile-card"), not the CSS class selector assert.ok( !html.includes('id="profile-card"'), 'Should NOT include profile card element', ); assert.ok( !html.includes('app.bsky.actor.getProfile'), 'Should NOT include profile fetch script', ); }); it('consent page escapes dangerous characters in login_hint', async () => { const clientId = 'http://localhost:3000'; const redirectUri = 'http://localhost:3000/callback'; const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!'; const challengeBuffer = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(codeVerifier), ); const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); // Attempt XSS via login_hint with double quotes to break out of JSON.stringify const maliciousHint = 'user");alert("xss'; const authorizeUrl = new URL(`${BASE}/oauth/authorize`); authorizeUrl.searchParams.set('client_id', clientId); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('scope', 'atproto'); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('state', 'test-state'); authorizeUrl.searchParams.set('login_hint', maliciousHint); const res = await fetch(authorizeUrl.toString()); const html = await res.text(); // JSON.stringify escapes double quotes, so the payload should be escaped // The raw ");alert(" should NOT appear - it should be escaped as \");alert(\" assert.ok( !html.includes('");alert("'), 'Should escape double quotes to prevent XSS breakout', ); // Verify the escaped version is present (backslash before the quote) assert.ok( html.includes('\\"'), 'Should contain escaped characters from JSON.stringify', ); }); }); describe('Foreign DID proxying', () => { it('proxies to AppView when atproto-proxy header present', async () => { // Use a known public DID (bsky.app official account) // We expect 200 (record exists) or 400 (record deleted/not found) from AppView // A 502 would indicate proxy failure, 404 would indicate local handling const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, { headers: { 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', }, }, ); // AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502 assert.ok( res.status === 200 || res.status === 400, `Expected 200 or 400 from AppView, got ${res.status}`, ); // Verify we got a JSON response (not an error page) const contentType = res.headers.get('content-type'); assert.ok( contentType?.includes('application/json'), 'Should return JSON', ); }); it('handles foreign repo locally without header (returns not found)', async () => { // Foreign DID without atproto-proxy header is handled locally // This returns an error since the foreign DID doesn't exist on this PDS const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, ); // Local PDS returns 404 for non-existent record/DID assert.strictEqual(res.status, 404); }); it('returns error for unknown proxy service', async () => { const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, { headers: { 'atproto-proxy': 'did:web:unknown.service#unknown', }, }, ); assert.strictEqual(res.status, 400); const data = await res.json(); assert.ok(data.message.includes('Unknown proxy service')); }); it('returns error for malformed atproto-proxy header', async () => { // Header without fragment separator const res1 = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, { headers: { 'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId }, }, ); assert.strictEqual(res1.status, 400); const data1 = await res1.json(); assert.ok(data1.message.includes('Malformed atproto-proxy header')); // Header with only fragment const res2 = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, { headers: { 'atproto-proxy': '#bsky_appview', // missing DID }, }, ); assert.strictEqual(res2.status, 400); const data2 = await res2.json(); assert.ok(data2.message.includes('Malformed atproto-proxy header')); }); it('returns local record for local DID without proxy header', async () => { // Create a record first const { data: created } = await jsonPost( '/xrpc/com.atproto.repo.createRecord', { repo: DID, collection: 'app.bsky.feed.post', record: { $type: 'app.bsky.feed.post', text: 'Test post for local DID test', createdAt: new Date().toISOString(), }, }, { Authorization: `Bearer ${token}` }, ); // Fetch without proxy header - should get local record const rkey = created.uri.split('/').pop(); const res = await fetch( `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, ); assert.strictEqual(res.status, 200); const data = await res.json(); assert.ok(data.value.text.includes('Test post for local DID test')); // Cleanup - verify success to ensure test isolation const { status: cleanupStatus } = await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(cleanupStatus, 200, 'Cleanup should succeed'); }); it('describeRepo handles foreign DID locally', async () => { // Without proxy header, foreign DID is handled locally (returns error) const res = await fetch( `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, ); // Local PDS returns 404 for non-existent DID assert.strictEqual(res.status, 404); }); it('listRecords handles foreign DID locally', async () => { // Without proxy header, foreign DID is handled locally // listRecords returns 200 with empty records for non-existent collection const res = await fetch( `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, ); // Local PDS returns 200 with empty records (or 404 for completely unknown DID) assert.ok( res.status === 200 || res.status === 404, `Expected 200 or 404, got ${res.status}`, ); }); }); describe('Cleanup', () => { it('deleteRecord (cleanup)', async () => { const { status } = await jsonPost( '/xrpc/com.atproto.repo.deleteRecord', { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey }, { Authorization: `Bearer ${token}` }, ); assert.strictEqual(status, 200); }); }); });