chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Proxy tests - service resolution from a DID document, and the token minted
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
generateKeyPair,
importPrivateKey,
} from '../packages/core/src/crypto.js';
import { createProxy } from '../packages/core/src/proxy.js';
afterEach(() => {
vi.unstubAllGlobals();
});
const DID = 'did:plc:proxytest';
const FEEDGEN = 'did:web:feed.example.com';
/**
* @param {{didDoc?: unknown, didResolver?: null, auth?: {did: string, scope: string}|null, scopeError?: Response|null}} [options]
*/
async function createTestProxy({
didDoc,
didResolver,
auth = null,
scopeError = null,
} = {}) {
const { privateKey } = await generateKeyPair();
const signingKey = await importPrivateKey(privateKey);
/** @type {Array<{url: string, init: RequestInit}>} */
const sent = [];
const fetchMock = vi.fn(async (/** @type {string} */ url, init) => {
sent.push({ url, init });
return new Response('upstream', { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const proxy = createProxy({
appviewUrl: 'https://appview.example.com',
appviewDid: 'did:web:appview.example.com',
didResolver:
didResolver === null ? null : async () => /** @type {any} */ (didDoc),
getSigningKey: async () => signingKey,
authenticate: async () => auth,
checkRpcScope: () => scopeError,
});
return { proxy, sent, fetchMock };
}
/**
* @param {Response|null} response
* @returns {Response}
*/
function answered(response) {
if (!response) throw new Error('the proxy returned no response');
return response;
}
/** @param {Record<string, string>} headers */
function req(headers, path = '/xrpc/app.bsky.feed.getFeed') {
return new Request(`https://pds.example.com${path}`, { headers });
}
describe('resolveServiceEndpoint', () => {
it('takes the first-party services without a lookup', async () => {
const resolver = vi.fn();
const withSpy = createProxy({
appviewUrl: 'https://appview.example.com',
didResolver: resolver,
getSigningKey: async () => null,
authenticate: async () => null,
checkRpcScope: () => null,
});
expect(
await withSpy.resolveServiceEndpoint(
'did:web:api.bsky.app',
'bsky_appview',
),
).toBe('https://api.bsky.app');
expect(
await withSpy.resolveServiceEndpoint(
'did:web:api.bsky.chat',
'bsky_chat',
),
).toBe('https://api.bsky.chat');
expect(resolver).not.toHaveBeenCalled();
});
it('reads the endpoint out of a DID document', async () => {
const { proxy } = await createTestProxy({
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com/' },
],
},
});
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(
'https://feed.example.com',
);
});
it('accepts a service id written in full', async () => {
const { proxy } = await createTestProxy({
didDoc: {
service: [
{
id: `${FEEDGEN}#bsky_fg`,
serviceEndpoint: 'https://feed.example.com',
},
],
},
});
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(
'https://feed.example.com',
);
});
it('refuses an endpoint that is not https', async () => {
const { proxy } = await createTestProxy({
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'http://feed.example.com' },
],
},
});
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(null);
});
it('is null when the document names no such service', async () => {
const { proxy } = await createTestProxy({
didDoc: {
service: [{ id: '#other', serviceEndpoint: 'https://x.test' }],
},
});
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(null);
});
it('is null when the document has no services', async () => {
const { proxy } = await createTestProxy({ didDoc: {} });
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(null);
});
it('is null when resolution throws', async () => {
const proxy = createProxy({
appviewUrl: 'https://appview.example.com',
didResolver: async () => {
throw new Error('offline');
},
getSigningKey: async () => null,
authenticate: async () => null,
checkRpcScope: () => null,
});
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(null);
});
it('is null with no resolver injected', async () => {
const { proxy } = await createTestProxy({ didResolver: null });
expect(await proxy.resolveServiceEndpoint(FEEDGEN, 'bsky_fg')).toBe(null);
});
});
describe('handleProxyHeader', () => {
it('passes the request through without the header', async () => {
const { proxy } = await createTestProxy();
expect(await proxy.handleProxyHeader(req({}))).toBe(null);
});
it('rejects a header it cannot parse', async () => {
const { proxy } = await createTestProxy();
const response = await proxy.handleProxyHeader(
req({ 'atproto-proxy': 'did:web:no-fragment' }),
);
expect(response?.status).toBe(400);
expect((await answered(response).json()).message).toMatch(/Malformed/);
});
it('rejects a service it cannot resolve', async () => {
const { proxy } = await createTestProxy({ didDoc: {} });
const response = await proxy.handleProxyHeader(
req({ 'atproto-proxy': `${FEEDGEN}#bsky_fg` }),
);
expect(response?.status).toBe(400);
expect((await answered(response).json()).message).toMatch(
/Unknown proxy service/,
);
});
it('forwards a public read unsigned', async () => {
const { proxy, sent } = await createTestProxy({
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com' },
],
},
});
const response = await proxy.handleProxyHeader(
req({ 'atproto-proxy': `${FEEDGEN}#bsky_fg` }),
);
expect(response?.status).toBe(200);
expect(sent[0].url).toBe(
'https://feed.example.com/xrpc/app.bsky.feed.getFeed',
);
expect(
/** @type {Headers} */ (sent[0].init.headers).get('Authorization'),
).toBe(null);
});
// The audience is the service the header named, not the AppView. A token
// addressed elsewhere is what made chat.bsky.* answer 400.
it('signs for the service the header named', async () => {
const { proxy, sent } = await createTestProxy({
auth: { did: DID, scope: 'com.atproto.access' },
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com' },
],
},
});
await proxy.handleProxyHeader(
req({ 'atproto-proxy': `${FEEDGEN}#bsky_fg` }),
);
const header = /** @type {Headers} */ (sent[0].init.headers).get(
'Authorization',
);
const jwt = String(header).replace('Bearer ', '');
const claims = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
),
),
);
expect(claims.iss).toBe(DID);
expect(claims.aud).toBe(FEEDGEN);
expect(claims.lxm).toBe('app.bsky.feed.getFeed');
});
it('stops a call the scope does not cover', async () => {
const denied = Response.json({ error: 'InvalidScope' }, { status: 403 });
const { proxy, sent } = await createTestProxy({
auth: { did: DID, scope: 'repo:app.bsky.feed.post' },
scopeError: denied,
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com' },
],
},
});
const response = await proxy.handleProxyHeader(
req({ 'atproto-proxy': `${FEEDGEN}#bsky_fg` }),
);
expect(response?.status).toBe(403);
expect(sent).toHaveLength(0);
});
});
describe('handleAppViewProxy', () => {
it('signs for the configured appview and forwards there', async () => {
const { proxy, sent } = await createTestProxy();
await proxy.handleAppViewProxy(req({}), DID);
expect(sent[0].url).toBe(
'https://appview.example.com/xrpc/app.bsky.feed.getFeed',
);
const jwt = String(
/** @type {Headers} */ (sent[0].init.headers).get('Authorization'),
).replace('Bearer ', '');
const claims = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
),
),
);
expect(claims.aud).toBe('did:web:appview.example.com');
});
it('throws without a signing key', async () => {
const proxy = createProxy({
appviewUrl: 'https://appview.example.com',
didResolver: null,
getSigningKey: async () => null,
authenticate: async () => null,
checkRpcScope: () => null,
});
await expect(proxy.createServiceAuthForAppView(DID, 'x')).rejects.toThrow(
/No signing key/,
);
});
});
describe('proxyToService', () => {
it('answers 502 when the service cannot be reached', async () => {
const { proxy } = await createTestProxy({
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com' },
],
},
});
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('connection refused');
}),
);
const response = await proxy.handleProxyHeader(
req({ 'atproto-proxy': `${FEEDGEN}#bsky_fg` }),
);
expect(response?.status).toBe(502);
const body = await answered(response).json();
expect(body.error).toBe('UpstreamFailure');
expect(body.message).toMatch(/connection refused/);
});
it('forwards the atproto headers a client sends', async () => {
const { proxy, sent } = await createTestProxy({
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com' },
],
},
});
await proxy.handleProxyHeader(
req({
'atproto-proxy': `${FEEDGEN}#bsky_fg`,
'atproto-accept-labelers': 'did:plc:labeler',
'x-bsky-topics': 'topic-a',
'x-bsky-is-beta-user': 'true',
Accept: 'application/json',
'Accept-Language': 'en-GB',
}),
);
const headers = /** @type {Headers} */ (sent[0].init.headers);
expect(headers.get('atproto-accept-labelers')).toBe('did:plc:labeler');
expect(headers.get('x-bsky-topics')).toBe('topic-a');
expect(headers.get('x-bsky-is-beta-user')).toBe('true');
expect(headers.get('Accept')).toBe('application/json');
expect(headers.get('Accept-Language')).toBe('en-GB');
});
it('keeps atproto-proxy from travelling on to the target', async () => {
const { proxy, sent } = await createTestProxy({
didDoc: {
service: [
{ id: '#bsky_fg', serviceEndpoint: 'https://feed.example.com' },
],
},
});
await proxy.handleProxyHeader(
req({ 'atproto-proxy': `${FEEDGEN}#bsky_fg` }),
);
const headers = /** @type {Headers} */ (sent[0].init.headers);
expect(headers.get('atproto-proxy')).toBe(null);
});
});