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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
603 lines · 18.9 KB Raw History
import { base64UrlEncode } from '@pdsjs/core/crypto';
import { mintDpopNonce, renderConsentPage } from '@pdsjs/core/oauth';
import { PersonalDataServer } from '@pdsjs/core/pds';
import {
  expandPermissionSet,
  expandScopeString,
  parseIncludeScope,
  parseScopesForDisplay,
  permissionSetDef,
  ScopePermissions,
} from '@pdsjs/core/scope';
import { describe, expect, it } from 'vitest';

const SET_NSID = 'social.grain.authFullApp';
const AUD = 'did:web:api.grain.social#grain';

/** A permission set exercising every containment and audience rule at once. */
const GRAIN_SET = {
  lexicon: 1,
  id: SET_NSID,
  defs: {
    main: {
      type: 'permission-set',
      title: 'Full grain access',
      'title:fr': 'Accès complet à grain',
      detail: 'Manage galleries, photos, and comments.',
      permissions: [
        {
          type: 'permission',
          resource: 'repo',
          collection: ['social.grain.gallery', 'social.grain.photo'],
        },
        {
          type: 'permission',
          resource: 'repo',
          collection: 'social.grain.comment',
          action: ['create'],
        },
        {
          type: 'permission',
          resource: 'rpc',
          inheritAud: true,
          lxm: ['social.grain.actor.getProfile'],
        },
        // Outside the set's authority: must not be granted.
        {
          type: 'permission',
          resource: 'repo',
          collection: 'app.bsky.feed.post',
        },
        // Wildcards are never grantable from a set.
        { type: 'permission', resource: 'repo', collection: 'social.grain.*' },
        // A concrete aud baked into a set must not be granted.
        {
          type: 'permission',
          resource: 'rpc',
          aud: 'did:web:evil.example#svc',
          lxm: ['social.grain.feed.getTimeline'],
        },
        // Resources other than repo/rpc are not grantable from a set.
        { type: 'permission', resource: 'blob', accept: ['image/*'] },
      ],
    },
  },
};

const EXPANDED = [
  'repo:social.grain.gallery',
  'repo:social.grain.photo',
  'repo:social.grain.comment?action=create',
  `rpc:social.grain.actor.getProfile?aud=${encodeURIComponent(AUD)}`,
];

describe('parseIncludeScope', () => {
  it('parses nsid and aud', () => {
    expect(parseIncludeScope(`include:${SET_NSID}?aud=${AUD}`)).toEqual({
      nsid: SET_NSID,
      aud: AUD,
    });
  });

  it('parses a bare include without aud', () => {
    expect(parseIncludeScope(`include:${SET_NSID}`)).toEqual({
      nsid: SET_NSID,
      aud: null,
    });
  });

  it('rejects non-include scopes, short names, wildcards, and bad auds', () => {
    expect(parseIncludeScope('repo:social.grain.photo')).toBeNull();
    expect(parseIncludeScope('include:grain')).toBeNull();
    expect(parseIncludeScope('include:social.grain.*')).toBeNull();
    expect(parseIncludeScope(`include:${SET_NSID}?aud=nonsense`)).toBeNull();
  });
});

describe('permissionSetDef', () => {
  it('accepts a permission-set main def', () => {
    expect(permissionSetDef(GRAIN_SET)?.title).toBe('Full grain access');
  });

  it('rejects other lexicon shapes', () => {
    expect(permissionSetDef(null)).toBeNull();
    expect(permissionSetDef({})).toBeNull();
    expect(permissionSetDef({ defs: { main: { type: 'record' } } })).toBeNull();
    expect(
      permissionSetDef({ defs: { main: { type: 'permission-set' } } }),
    ).toBeNull();
  });
});

describe('expandPermissionSet', () => {
  it('grants only contained, non-wildcard, audience-inherited permissions', () => {
    expect(expandPermissionSet(SET_NSID, GRAIN_SET, AUD)).toEqual(EXPANDED);
  });

  it('drops rpc grants entirely when no aud is inherited', () => {
    const scopes = expandPermissionSet(SET_NSID, GRAIN_SET, null);
    expect(scopes).toEqual(EXPANDED.slice(0, 3));
  });

  it('returns null for a document that is not a permission set', () => {
    expect(expandPermissionSet(SET_NSID, { defs: {} }, AUD)).toBeNull();
  });
});

describe('expandPermissionSet with space permissions', () => {
  const CAL_NSID = 'com.example.calendar.auth';

  /** @param {object} permission */
  const expandOne = (permission) =>
    expandPermissionSet(CAL_NSID, {
      lexicon: 1,
      id: CAL_NSID,
      defs: { main: { type: 'permission-set', permissions: [permission] } },
    });

  it('drops the query when the actions are the default four', () => {
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        action: ['read', 'create', 'update', 'delete'],
      }),
    ).toEqual(['space:com.example.calendar.group']);
  });

  it('names the collections and a narrowed action list', () => {
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        collection: ['com.example.calendar.event'],
        action: ['create', 'update'],
      }),
    ).toEqual([
      'space:com.example.calendar.group?collection=com.example.calendar.event&action=create&action=update',
    ]);
  });

  it('allows a collection under another authority, and a wildcard one', () => {
    // The space type is authority-checked; its collections belong to that
    // type's declaration, which another authority may own.
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        collection: ['app.bsky.feed.post'],
      }),
    ).toEqual([
      'space:com.example.calendar.group?collection=app.bsky.feed.post',
    ]);
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        collection: ['*'],
      }),
    ).toEqual(['space:com.example.calendar.group?collection=*']);
  });

  it('refuses a space type outside the set authority, or a wildcard one', () => {
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'app.bsky.group',
      }),
    ).toEqual([]);
    expect(
      expandOne({ type: 'permission', resource: 'space', spaceType: '*' }),
    ).toEqual([]);
  });

  it('refuses an action list this server cannot read', () => {
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        action: ['transmute'],
      }),
    ).toEqual([]);
  });

  it('carries a set through for both its record actions and its manage verbs', () => {
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        manage: ['create', 'delete'],
        action: ['read'],
      }),
    ).toEqual([
      'space:com.example.calendar.group?action=read&manage=create&manage=delete',
    ]);
  });

  it('drops a manage op this server cannot read, and keeps the actions', () => {
    // An absent manage param grants no verb, so an unreadable list cannot
    // widen the grant the way an unreadable action list would.
    expect(
      expandOne({
        type: 'permission',
        resource: 'space',
        spaceType: 'com.example.calendar.group',
        manage: ['transmute'],
        action: ['read'],
      }),
    ).toEqual(['space:com.example.calendar.group?action=read']);
  });

  // The set app.bulleted.spaceAccess publishes, verbatim. `authority=*` is the
  // point of it: without that param the scope means authority=self, and a
  // space another account hosts never matches.
  it('carries authority=* through, so a shared space is reachable', () => {
    const BULLETED_NSID = 'app.bulleted.spaceAccess';
    const scopes = expandPermissionSet(BULLETED_NSID, {
      lexicon: 1,
      id: BULLETED_NSID,
      defs: {
        main: {
          type: 'permission-set',
          title: 'Bulleted spaces',
          detail: 'Read the outlines shared in your Bulleted spaces.',
          permissions: [
            {
              type: 'permission',
              resource: 'space',
              spaceType: 'app.bulleted.space',
              authority: '*',
              collection: [
                'app.bulleted.node',
                'app.bulleted.note',
                'app.bulleted.outline',
                'app.bulleted.mirror',
                'app.bulleted.comment',
                'app.bulleted.commentPolicy',
              ],
              action: ['read', 'create', 'update', 'delete'],
            },
          ],
        },
      },
    });

    expect(scopes).toEqual([
      'space:app.bulleted.space?authority=*&collection=app.bulleted.node' +
        '&collection=app.bulleted.note&collection=app.bulleted.outline' +
        '&collection=app.bulleted.mirror&collection=app.bulleted.comment' +
        '&collection=app.bulleted.commentPolicy',
    ]);

    // The granted scope reaches a space the app's own account hosts.
    const permissions = new ScopePermissions(
      /** @type {string[]} */ (scopes).join(' '),
    );
    expect(
      permissions.allowsSpace({
        spaceType: 'app.bulleted.space',
        authority: 'did:plc:iu5fzdrrfrc6kk7vmmatvin2',
        skey: '3msw44754okhn',
        action: 'read',
        userDid: 'did:plc:bcgltzqazw5tb6k2g3ttenbj',
      }),
    ).toBe(true);
  });

  // The set Bulletin publishes, verbatim. `manage` is the point of it: the app
  // gates its own board-creation screen on a manage=create grant, and
  // com.atproto.space.createSpace checks the same grant.
  it('carries manage through, so a client can create its own space', () => {
    const BULLETIN_NSID = 'my.bulletin.permissions';
    const scopes = expandPermissionSet(BULLETIN_NSID, {
      lexicon: 1,
      id: BULLETIN_NSID,
      defs: {
        main: {
          type: 'permission-set',
          title: 'Bulletin',
          detail: 'Read and post on Bulletin boards',
          permissions: [
            {
              type: 'permission',
              resource: 'space',
              spaceType: 'my.bulletin.board',
              authority: '*',
              skey: 'self',
              collection: [
                'my.bulletin.post',
                'my.bulletin.removal',
                'my.bulletin.position',
              ],
              action: ['read', 'create', 'update', 'delete'],
              manage: ['create', 'update', 'delete'],
            },
          ],
        },
      },
    });

    expect(scopes).toEqual([
      'space:my.bulletin.board?authority=*&skey=self' +
        '&collection=my.bulletin.post&collection=my.bulletin.removal' +
        '&collection=my.bulletin.position' +
        '&manage=create&manage=update&manage=delete',
    ]);

    const permissions = new ScopePermissions(
      /** @type {string[]} */ (scopes).join(' '),
    );
    const userDid = 'did:plc:bcgltzqazw5tb6k2g3ttenbj';
    expect(
      permissions.allowsSpaceManage({
        spaceType: 'my.bulletin.board',
        authority: userDid,
        skey: 'self',
        op: 'create',
        userDid,
      }),
    ).toBe(true);
    expect(
      permissions.allowsSpace({
        spaceType: 'my.bulletin.board',
        authority: userDid,
        skey: 'self',
        collection: 'my.bulletin.post',
        action: 'create',
        userDid,
      }),
    ).toBe(true);
  });
});

describe('expandScopeString', () => {
  const scope = `atproto include:${SET_NSID}?aud=${AUD} repo:com.other.thing`;

  it('replaces include tokens and keeps the rest verbatim', () => {
    expect(
      expandScopeString(scope, { [SET_NSID]: GRAIN_SET }).split(' '),
    ).toEqual(['atproto', ...EXPANDED, 'repo:com.other.thing']);
  });

  it('expands an unresolved include to nothing rather than something', () => {
    expect(expandScopeString(scope, {})).toBe('atproto repo:com.other.thing');
  });

  it('falls back to atproto when nothing survives', () => {
    expect(expandScopeString(`include:${SET_NSID}`, {})).toBe('atproto');
  });
});

describe('parseScopesForDisplay additions', () => {
  it('merges rpc grants by audience', () => {
    const parsed = parseScopesForDisplay(
      `rpc:com.a.one?aud=${AUD} rpc:com.a.two?aud=${AUD} rpc:com.b.x?aud=*`,
    );
    expect(parsed.rpcPermissions).toEqual([
      { aud: AUD, lxms: ['com.a.one', 'com.a.two'] },
      { aud: '*', lxms: ['com.b.x'] },
    ]);
  });

  it('lists include scopes', () => {
    const parsed = parseScopesForDisplay(
      `atproto include:${SET_NSID}?aud=${AUD}`,
    );
    expect(parsed.includeScopes).toEqual([{ nsid: SET_NSID, aud: AUD }]);
  });
});

describe('legacy consent page with permission sets', () => {
  it('shows the set title and detail', () => {
    const html = renderConsentPage({
      clientName: 'Test App',
      clientId: 'https://app.example/client-metadata.json',
      scope: `atproto include:${SET_NSID}?aud=${AUD}`,
      requestUri: 'urn:ietf:params:oauth:request_uri:x',
      permissionSets: { [SET_NSID]: GRAIN_SET },
    });
    expect(html).toContain('Full grain access');
    expect(html).toContain('Manage galleries, photos, and comments.');
    expect(html).not.toContain('uniquely identify');
  });

  it('falls back to the NSID when the set is missing', () => {
    const html = renderConsentPage({
      clientName: 'Test App',
      clientId: 'https://app.example/client-metadata.json',
      scope: `atproto include:${SET_NSID}`,
      requestUri: 'urn:ietf:params:oauth:request_uri:x',
    });
    expect(html).toContain(SET_NSID);
  });
});

describe('PAR include-scope resolution', () => {
  /**
   * The smallest server able to reach the scope checks in handleOAuthPar.
   * @param {object|null} lexiconResolver
   */
  function makeServer(lexiconResolver) {
    /** @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 });
          },
        },
        ...(lexiconResolver ? { lexiconResolver } : {}),
      }),
    );
    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',
        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);
  }

  const resolver = {
    assertValid: async () => 'valid',
    getLexicon: async (/** @type {string} */ nsid) =>
      nsid === SET_NSID ? GRAIN_SET : null,
  };

  const INCLUDE_SCOPE = `atproto include:${SET_NSID}?aud=${AUD}`;

  it('stores the resolved permission set alongside the request', async () => {
    const { pds, stored } = makeServer(resolver);
    const res = await par(pds, INCLUDE_SCOPE);
    expect(res.status).toBe(201);
    expect(stored).toHaveLength(1);
    expect(stored[0].data.scope).toBe(INCLUDE_SCOPE);
    expect(stored[0].data.permissionSets).toEqual({ [SET_NSID]: GRAIN_SET });
  });

  it('refuses an include whose lexicon does not resolve', async () => {
    const { pds, stored } = makeServer(resolver);
    const res = await par(pds, 'atproto include:com.unknown.set');
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toBe('invalid_scope');
    expect(stored).toHaveLength(0);
  });

  it('refuses an include that resolves to a non-permission-set lexicon', async () => {
    const record = { defs: { main: { type: 'record' } } };
    const { pds } = makeServer({
      assertValid: async () => 'valid',
      getLexicon: async () => record,
    });
    const res = await par(pds, INCLUDE_SCOPE);
    expect(res.status).toBe(400);
  });

  it('refuses include scopes when no lexicon resolver is configured', async () => {
    const { pds } = makeServer(null);
    const res = await par(pds, INCLUDE_SCOPE);
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error).toBe('invalid_scope');
  });

  it('leaves include-free scopes alone without a resolver', async () => {
    const { pds } = makeServer(null);
    const res = await par(pds, 'atproto repo:com.example.post');
    expect(res.status).toBe(201);
  });
});

describe('approval expands include scopes', () => {
  it('freezes the expansion into the granted scope', async () => {
    /** @type {any[]} */
    const stored = [];
    const pds = new PersonalDataServer(
      /** @type {any} */ ({
        jwtSecret: 'test-secret',
        sharedStorage: {
          putOAuthRequest: async (
            /** @type {string} */ uri,
            /** @type {any} */ data,
          ) => {
            stored.push({ uri, data });
          },
        },
      }),
    );
    /** @type {any} */ (pds).getDid = async () => 'did:plc:me';

    const authRequest = /** @type {any} */ ({
      clientId: 'http://localhost',
      redirectUri: 'http://localhost/callback',
      scope: `atproto include:${SET_NSID}?aud=${AUD}`,
      permissionSets: { [SET_NSID]: GRAIN_SET },
      codeChallenge: 'x',
      codeChallengeMethod: 'S256',
      dpopJkt: null,
    });

    const redirect = await pds._oauth.issueAuthorizationCode(
      authRequest,
      'urn:ietf:params:oauth:request_uri:x',
      'https://pds.example',
    );
    expect(redirect).toContain('http://localhost/callback');
    expect(authRequest.scope.split(' ')).toEqual(['atproto', ...EXPANDED]);
    expect(authRequest.requestedScope).toBe(
      `atproto include:${SET_NSID}?aud=${AUD}`,
    );
    // The stored request carries the expanded grant for the token exchange.
    expect(stored[0].data.scope).toBe(authRequest.scope);
  });
});