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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
160 lines · 5.2 KB Raw History
import { base64UrlEncode } from '@pdsjs/core/crypto';
import { mintDpopNonce, renderConsentPage } from '@pdsjs/core/oauth';
import { PersonalDataServer } from '@pdsjs/core/pds';
import { describe, expect, it } from 'vitest';

const SPACE_SCOPE =
  'atproto space:com.example.forum?authority=did:plc:aaaa&collection=com.example.post&manage=create';

describe('consent page with space scopes', () => {
  /** @param {string} scope */
  const render = (scope) =>
    renderConsentPage({
      clientName: 'Test App',
      clientId: 'https://app.example/client-metadata.json',
      scope,
      requestUri: 'urn:ietf:params:oauth:request_uri:x',
    });

  it('renders space grants as their own section', () => {
    const html = render(SPACE_SCOPE);
    expect(html).toContain('Private space permissions');
    expect(html).toContain('com.example.forum');
    expect(html).toContain('spaces under did:plc:aaaa');
    expect(html).toContain('com.example.post');
    expect(html).toContain('Manage spaces: create');
  });

  it('does not present a space grant as identity-only access', () => {
    // "Identity only" understates a grant that can read and write private
    // spaces; that copy is reserved for a bare `atproto` scope.
    const html = render('atproto space:com.example.forum');
    expect(html).not.toContain('uniquely identify');
    expect(html).toContain('wants to access your account');
  });

  it('keeps the identity-only copy for a bare atproto scope', () => {
    const html = render('atproto');
    expect(html).toContain('uniquely identify');
  });

  it('labels self-authority grants and read_self plainly', () => {
    const html = render('atproto space:com.example.forum?action=read_self');
    expect(html).toContain('your spaces');
    expect(html).toContain('read own records');
  });
});

describe('PAR space-scope gating', () => {
  /**
   * The smallest server able to reach the scope check in handleOAuthPar.
   * @param {boolean} spaces
   */
  function makeServer(spaces) {
    /** @type {any[]} */
    const stored = [];
    const pds = new PersonalDataServer(
      /** @type {any} */ ({
        jwtSecret: 'test-secret',
        sharedStorage: {
          checkAndStoreDpopJti: async () => true,
          putOAuthRequest: async (
            /** @type {string} */ uri,
            /** @type {any} */ data,
          ) => {
            stored.push({ uri, data });
          },
        },
        ...(spaces ? { spaceStorage: {} } : {}),
      }),
    );
    return { pds, stored };
  }

  /**
   * A valid DPoP proof for POST /oauth/par, carrying the server nonce.
   * @param {string} htu
   */
  async function dpopProof(htu) {
    const keys = await crypto.subtle.generateKey(
      { name: 'ECDSA', namedCurve: 'P-256' },
      true,
      ['sign', 'verify'],
    );
    const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
    const header = {
      typ: 'dpop+jwt',
      alg: 'ES256',
      jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
    };
    const payload = {
      jti: crypto.randomUUID(),
      htm: 'POST',
      htu,
      iat: Math.floor(Date.now() / 1000),
      nonce: await mintDpopNonce('test-secret'),
    };
    const enc = (/** @type {object} */ o) =>
      base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
    const input = `${enc(header)}.${enc(payload)}`;
    const sig = new Uint8Array(
      await crypto.subtle.sign(
        { name: 'ECDSA', hash: 'SHA-256' },
        keys.privateKey,
        new TextEncoder().encode(input),
      ),
    );
    return `${input}.${base64UrlEncode(sig)}`;
  }

  /**
   * @param {PersonalDataServer} pds
   * @param {string} scope
   */
  async function par(pds, scope) {
    const url = new URL('https://pds.example/oauth/par');
    const request = new Request(url, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        DPoP: await dpopProof('https://pds.example/oauth/par'),
      },
      body: JSON.stringify({
        client_id: 'http://localhost',
        // Loopback metadata registers the client_id itself as the redirect
        // origin, so the redirect must share it.
        redirect_uri: 'http://localhost/callback',
        response_type: 'code',
        code_challenge: base64UrlEncode(
          crypto.getRandomValues(new Uint8Array(32)),
        ),
        code_challenge_method: 'S256',
        scope,
      }),
    });
    return pds._oauth.handleOAuthPar(request, url);
  }

  it('refuses a space: scope when spaces are disabled', async () => {
    const { pds, stored } = makeServer(false);
    const res = await par(pds, SPACE_SCOPE);
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toBe('invalid_scope');
    expect(stored).toHaveLength(0);
  });

  it('accepts the same request when spaces are enabled', async () => {
    const { pds, stored } = makeServer(true);
    const res = await par(pds, SPACE_SCOPE);
    expect(res.status).toBe(201);
    expect(stored).toHaveLength(1);
    expect(stored[0].data.scope).toBe(SPACE_SCOPE);
  });

  it('leaves non-space scopes alone when spaces are disabled', async () => {
    const { pds } = makeServer(false);
    const res = await par(pds, 'atproto repo:com.example.post');
    expect(res.status).toBe(201);
  });
});