~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
381 lines · 11.1 KB Raw History
// 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);
  });
});