// 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} 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); }); });