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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
266 lines · 8.4 KB Raw History
import { beforeEach, describe, expect, it } from 'vitest';
import { SCOPE_ACCESS } from '../packages/core/src/app-password.js';
import { createAccessJwt } from '../packages/core/src/auth.js';
import { renderConsentPage } from '../packages/core/src/oauth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import {
  parseScopesForDisplay,
  ScopePermissions,
} from '../packages/core/src/scope.js';

const DID = 'did:plc:testemailscope';
const JWT_SECRET = 'test-secret';
const ACCOUNT_PASSWORD = 'account-password';
const EMAIL = 'alice@example.com';

describe('email scope semantics', () => {
  describe('ScopePermissions.hasEmailAccess', () => {
    it('grants on transition:email', () => {
      expect(
        new ScopePermissions('atproto transition:email').hasEmailAccess(),
      ).toBe(true);
    });

    it('grants on granular account:email, bare or parameterized', () => {
      expect(new ScopePermissions('account:email').hasEmailAccess()).toBe(true);
      expect(
        new ScopePermissions('account:email?action=read').hasEmailAccess(),
      ).toBe(true);
    });

    it('is not implied by full access', () => {
      // The reference PDS keeps email separate: broad repo access alone does
      // not expose the address.
      expect(new ScopePermissions('atproto').hasEmailAccess()).toBe(false);
      expect(new ScopePermissions('transition:generic').hasEmailAccess()).toBe(
        false,
      );
      expect(new ScopePermissions(SCOPE_ACCESS).hasEmailAccess()).toBe(false);
    });

    it('is false for an empty scope', () => {
      expect(new ScopePermissions('').hasEmailAccess()).toBe(false);
    });
  });

  describe('parseScopesForDisplay', () => {
    it('flags hasEmail when the email scope is present', () => {
      expect(parseScopesForDisplay('atproto transition:email').hasEmail).toBe(
        true,
      );
      expect(parseScopesForDisplay('account:email').hasEmail).toBe(true);
    });

    it('leaves hasEmail false otherwise', () => {
      expect(parseScopesForDisplay('atproto transition:generic').hasEmail).toBe(
        false,
      );
    });
  });

  describe('consent page', () => {
    const base = {
      clientName: 'Test App',
      clientId: 'https://app.example.com/client-metadata.json',
      requestUri: 'urn:ietf:params:oauth:request_uri:abc',
    };

    it('tells the user their email will be visible', () => {
      const html = renderConsentPage({
        ...base,
        scope: 'atproto transition:email',
      });
      expect(html).toContain('email address');
    });

    it('mentions email even alongside full repository access', () => {
      const html = renderConsentPage({
        ...base,
        scope: 'atproto transition:generic transition:email',
      });
      expect(html).toContain('email address');
      expect(html).toContain('Full repository access');
    });

    it('says nothing about email when the scope is not requested', () => {
      const html = renderConsentPage({ ...base, scope: 'atproto' });
      expect(html).not.toContain('email address');
    });
  });
});

/**
 * @param {import('../packages/core/src/ports.js').EmailState} emailState
 */
function createPds(emailState) {
  /** @type {Map<string, {name: string, hash: string, privileged: boolean, createdAt: string}>} */
  const byName = new Map();
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({
      async getHandle() {
        return 'alice.test';
      },
      async getDid() {
        return DID;
      },
      async getEmail() {
        return emailState;
      },
      async setEmail() {},
    }),
    sharedStorage: /** @type {any} */ ({
      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;
      },
    }),
    blobs: /** @type {any} */ ({}),
    jwtSecret: JWT_SECRET,
    hostname: 'pds.example.com',
    password: ACCOUNT_PASSWORD,
  });
}

/**
 * @param {PersonalDataServer} pds
 * @param {string} jwt
 */
async function getSession(pds, jwt) {
  const response = await pds.fetch(
    new Request('https://pds.example.com/xrpc/com.atproto.server.getSession', {
      headers: { authorization: `Bearer ${jwt}` },
    }),
  );
  return { status: response.status, body: await response.json() };
}

describe('email in session responses', () => {
  /** @type {PersonalDataServer} */
  let pds;

  beforeEach(() => {
    pds = createPds({ address: EMAIL, verified: true, pending: null });
  });

  it('createSession returns the email to the account-password session', async () => {
    const res = 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: ACCOUNT_PASSWORD }),
        },
      ),
    );
    const body = await res.json();
    expect(body.email).toBe(EMAIL);
    expect(body.emailConfirmed).toBe(true);
  });

  it('getSession exposes email to the account-password session', async () => {
    const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS);
    const { body } = await getSession(pds, jwt);
    expect(body.email).toBe(EMAIL);
    expect(body.emailConfirmed).toBe(true);
  });

  it('reports an unverified address as unconfirmed', async () => {
    pds = createPds({ address: EMAIL, verified: false, pending: null });
    const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS);
    const { body } = await getSession(pds, jwt);
    expect(body.email).toBe(EMAIL);
    expect(body.emailConfirmed).toBe(false);
  });

  it('exposes email to an OAuth session holding transition:email', async () => {
    const jwt = await createAccessJwt(
      DID,
      JWT_SECRET,
      7200,
      'atproto transition:email',
    );
    const { body } = await getSession(pds, jwt);
    expect(body.email).toBe(EMAIL);
  });

  it('withholds email from an OAuth session without the email scope', async () => {
    const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, 'atproto');
    const { body } = await getSession(pds, jwt);
    expect(body.email).toBeUndefined();
    expect(body.emailConfirmed).toBeUndefined();
    // Identity is still returned.
    expect(body.did).toBe(DID);
  });

  it('never exposes email to an app-password session', async () => {
    // Register an app password, then log in with it for a restricted session.
    const accountJwt = await createAccessJwt(
      DID,
      JWT_SECRET,
      7200,
      SCOPE_ACCESS,
    );
    const created = await pds.fetch(
      new Request(
        'https://pds.example.com/xrpc/com.atproto.server.createAppPassword',
        {
          method: 'POST',
          headers: {
            'content-type': 'application/json',
            authorization: `Bearer ${accountJwt}`,
          },
          body: JSON.stringify({ name: 'my-client' }),
        },
      ),
    );
    const { password } = await created.json();

    const session = 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 }),
        },
      ),
    );
    const sessionBody = await session.json();
    expect(sessionBody.email).toBeUndefined();

    const { body } = await getSession(pds, sessionBody.accessJwt);
    expect(body.email).toBeUndefined();
    expect(body.emailConfirmed).toBeUndefined();
  });

  it('omits email fields when the account has no address', async () => {
    pds = createPds({ address: null, verified: false, pending: null });
    const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS);
    const { body } = await getSession(pds, jwt);
    expect(body.email).toBeUndefined();
    expect(body.emailConfirmed).toBeUndefined();
  });
});