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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
382 lines · 11.3 KB Raw History
import { beforeEach, describe, expect, it } from 'vitest';
import {
  generateAppPassword,
  hashAppPassword,
  SCOPE_ACCESS,
  SCOPE_APP_PASS,
  SCOPE_APP_PASS_PRIVILEGED,
} from '../packages/core/src/app-password.js';
import {
  verifyAccessJwt,
  verifyRefreshJwt,
} from '../packages/core/src/auth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const DID = 'did:plc:testapppassword';
const JWT_SECRET = 'test-secret';
const ACCOUNT_PASSWORD = 'account-password';

/** Minimal in-memory shared storage covering the app-password surface. */
function createSharedStorage() {
  /** @type {Map<string, {name: string, hash: string, privileged: boolean, createdAt: string}>} */
  const byName = new Map();
  return {
    async putAppPassword(
      /** @type {string} */ did,
      /** @type {string} */ name,
      /** @type {string} */ hash,
      /** @type {boolean} */ privileged,
      /** @type {string} */ createdAt,
    ) {
      if (byName.has(`${did}:${name}`)) return false;
      byName.set(`${did}:${name}`, { name, hash, privileged, createdAt });
      return true;
    },
    async getAppPasswordByHash(
      /** @type {string} */ did,
      /** @type {string} */ hash,
    ) {
      for (const [key, v] of byName) {
        if (key.startsWith(`${did}:`) && v.hash === hash) {
          return {
            name: v.name,
            privileged: v.privileged,
            createdAt: v.createdAt,
          };
        }
      }
      return null;
    },
    async listAppPasswords(/** @type {string} */ did) {
      return [...byName.entries()]
        .filter(([key]) => key.startsWith(`${did}:`))
        .map(([, v]) => ({
          name: v.name,
          privileged: v.privileged,
          createdAt: v.createdAt,
        }));
    },
    async deleteAppPassword(
      /** @type {string} */ did,
      /** @type {string} */ name,
    ) {
      return byName.delete(`${did}:${name}`);
    },
  };
}

function createPds() {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({
      async getHandle() {
        return 'alice.test';
      },
      async getDid() {
        return DID;
      },
    }),
    sharedStorage: /** @type {any} */ (createSharedStorage()),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: 'pds.example.com',
    password: ACCOUNT_PASSWORD,
  });
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} password
 */
async function login(pds, password) {
  const response = await pds.fetch(
    new Request(
      'https://pds.example.com/xrpc/com.atproto.server.createSession',
      {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ identifier: DID, password }),
      },
    ),
  );
  return { status: response.status, body: await response.json() };
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} method
 * @param {string} jwt
 * @param {Object} [body]
 */
async function call(pds, method, jwt, body) {
  const isPost = body !== undefined;
  const response = await pds.fetch(
    new Request(`https://pds.example.com/xrpc/${method}`, {
      method: isPost ? 'POST' : 'GET',
      headers: {
        authorization: `Bearer ${jwt}`,
        ...(isPost ? { 'content-type': 'application/json' } : {}),
      },
      ...(isPost ? { body: JSON.stringify(body) } : {}),
    }),
  );
  const text = await response.text();
  return { status: response.status, body: text ? JSON.parse(text) : null };
}

describe('app passwords', () => {
  /** @type {PersonalDataServer} */
  let pds;
  /** @type {string} */
  let accessJwt;

  beforeEach(async () => {
    pds = createPds();
    accessJwt = (await login(pds, ACCOUNT_PASSWORD)).body.accessJwt;
  });

  describe('generation', () => {
    it('produces the atproto xxxx-xxxx-xxxx-xxxx shape', () => {
      expect(generateAppPassword()).toMatch(/^[a-z2-7]{4}(-[a-z2-7]{4}){3}$/);
    });

    it('does not repeat', () => {
      const seen = new Set(Array.from({ length: 200 }, generateAppPassword));
      expect(seen.size).toBe(200);
    });

    it('hashes the same password differently for different accounts', async () => {
      const password = generateAppPassword();
      const a = await hashAppPassword('did:plc:one', password);
      const b = await hashAppPassword('did:plc:two', password);
      expect(a).not.toBe(b);
    });
  });

  describe('createAppPassword', () => {
    it('returns the password exactly once', async () => {
      const created = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        { name: 'my-client' },
      );
      expect(created.status).toBe(200);
      expect(created.body.name).toBe('my-client');
      expect(created.body.password).toMatch(/^[a-z2-7]{4}(-[a-z2-7]{4}){3}$/);
      expect(created.body.privileged).toBe(false);

      // Listing must never expose it again.
      const listed = await call(
        pds,
        'com.atproto.server.listAppPasswords',
        accessJwt,
      );
      expect(listed.body.passwords).toHaveLength(1);
      expect(listed.body.passwords[0]).not.toHaveProperty('password');
      expect(listed.body.passwords[0].name).toBe('my-client');
    });

    it('rejects a duplicate name rather than replacing it', async () => {
      const body = { name: 'dupe' };
      const first = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        body,
      );
      const second = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        body,
      );
      expect(second.status).toBe(400);

      // The original must still work — a rejected duplicate that silently
      // invalidated the first password would be worse than the error.
      const session = await login(pds, first.body.password);
      expect(session.status).toBe(200);
    });

    it('requires a name', async () => {
      const result = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        {},
      );
      expect(result.status).toBe(400);
    });
  });

  describe('login', () => {
    it('opens a restricted session', async () => {
      const created = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        { name: 'client' },
      );

      const session = await login(pds, created.body.password);
      expect(session.status).toBe(200);
      expect(session.body.did).toBe(DID);

      const payload = await verifyAccessJwt(session.body.accessJwt, JWT_SECRET);
      expect(payload.scope).toBe(SCOPE_APP_PASS);
    });

    it('marks privileged app passwords distinctly', async () => {
      const created = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        { name: 'trusted', privileged: true },
      );

      const session = await login(pds, created.body.password);
      const payload = await verifyAccessJwt(session.body.accessJwt, JWT_SECRET);
      expect(payload.scope).toBe(SCOPE_APP_PASS_PRIVILEGED);
    });

    it('still gives the account password a full session', async () => {
      const session = await login(pds, ACCOUNT_PASSWORD);
      const payload = await verifyAccessJwt(session.body.accessJwt, JWT_SECRET);
      expect(payload.scope).toBe(SCOPE_ACCESS);
    });

    it('rejects a wrong password', async () => {
      expect((await login(pds, 'nope-nope-nope-nope')).status).toBe(401);
    });

    it('rejects every password when none is configured', async () => {
      // `if (this.password && password !== this.password)` short-circuited
      // when no password was set, so any string — including the empty one —
      // opened a full-access session on an unconfigured server.
      const openPds = new PersonalDataServer({
        actorStorage: /** @type {any} */ ({
          async getHandle() {
            return 'alice.test';
          },
          async getDid() {
            return DID;
          },
        }),
        sharedStorage: /** @type {any} */ (createSharedStorage()),
        blobs: /** @type {any} */ ({}),
        jwtSecret: JWT_SECRET,
        hostname: 'pds.example.com',
        // password deliberately omitted
      });

      for (const attempt of ['anything', '', 'hunter2']) {
        const result = await login(openPds, attempt);
        expect(result.status).toBe(401);
        expect(result.body.accessJwt).toBeUndefined();
      }
    });

    it('rejects a revoked password', async () => {
      const created = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        { name: 'temp' },
      );
      expect((await login(pds, created.body.password)).status).toBe(200);

      const revoked = await call(
        pds,
        'com.atproto.server.revokeAppPassword',
        accessJwt,
        { name: 'temp' },
      );
      expect(revoked.status).toBe(200);

      expect((await login(pds, created.body.password)).status).toBe(401);
    });

    it('rejects revoking a name that does not exist', async () => {
      const result = await call(
        pds,
        'com.atproto.server.revokeAppPassword',
        accessJwt,
        { name: 'never-made' },
      );
      expect(result.status).toBe(400);
    });
  });

  describe('privilege containment', () => {
    /** @type {string} */
    let appJwt;

    beforeEach(async () => {
      const created = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        { name: 'client', privileged: true },
      );
      appJwt = (await login(pds, created.body.password)).body.accessJwt;
    });

    it('refuses to mint app passwords from an app-password session', async () => {
      // Otherwise one leaked password mints its own replacements and
      // revoking it accomplishes nothing.
      const result = await call(
        pds,
        'com.atproto.server.createAppPassword',
        appJwt,
        { name: 'escalated' },
      );
      expect(result.status).toBe(403);
    });

    it('refuses to list or revoke from an app-password session', async () => {
      expect(
        (await call(pds, 'com.atproto.server.listAppPasswords', appJwt)).status,
      ).toBe(403);
      expect(
        (
          await call(pds, 'com.atproto.server.revokeAppPassword', appJwt, {
            name: 'client',
          })
        ).status,
      ).toBe(403);
    });

    it('does not let a refresh escalate to a full session', async () => {
      const created = await call(
        pds,
        'com.atproto.server.createAppPassword',
        accessJwt,
        { name: 'refresher' },
      );
      const session = await login(pds, created.body.password);

      const refreshed = await pds.fetch(
        new Request(
          'https://pds.example.com/xrpc/com.atproto.server.refreshSession',
          {
            method: 'POST',
            headers: { authorization: `Bearer ${session.body.refreshJwt}` },
          },
        ),
      );
      const body = await refreshed.json();
      expect(refreshed.status).toBe(200);

      const payload = await verifyAccessJwt(body.accessJwt, JWT_SECRET);
      expect(payload.scope).toBe(SCOPE_APP_PASS);

      // And the new refresh token must carry the restriction forward too,
      // or the second refresh escalates instead of the first.
      const refreshPayload = await verifyRefreshJwt(
        body.refreshJwt,
        JWT_SECRET,
      );
      expect(refreshPayload.sessionScope).toBe(SCOPE_APP_PASS);
    });
  });
});