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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
127 lines · 3.6 KB Raw History
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createOAuthClient } from '../src/index.js';

class StorageMock {
  values = new Map();

  /** @param {string} key */
  getItem(key) {
    return this.values.get(key) ?? null;
  }

  /** @param {string} key @param {string} value */
  setItem(key, value) {
    this.values.set(key, String(value));
  }

  /** @param {string} key */
  removeItem(key) {
    this.values.delete(key);
  }
}

/** @type {ReturnType<typeof vi.fn>} */
let fetchMock;

/** @returns {import('../src/oauth.js').OAuthClient} */
function client() {
  const dpop = {
    dpopKeyPair: vi.fn(),
    forgetDpopKey: vi.fn(async () => {}),
    sha256Base64Url: vi.fn(async () => 'challenge'),
    dpopProof: vi.fn(async () => 'proof'),
    nonceFor: vi.fn(() => undefined),
    rememberNonce: vi.fn(),
  };
  return createOAuthClient({
    dpop: /** @type {any} */ (dpop),
    resolveIdentity: async () => ({
      did: 'did:plc:alice',
      handle: 'alice.example',
      pds: 'https://pds.example',
    }),
    scope: 'atproto',
    storagePrefix: 'oauth-client-test',
  });
}

beforeEach(() => {
  vi.stubGlobal('window', {
    location: {
      origin: 'http://localhost:5173',
      hostname: 'localhost',
      assign: vi.fn(() => {
        throw new Error('navigation');
      }),
    },
  });
  vi.stubGlobal('localStorage', new StorageMock());
  vi.stubGlobal('sessionStorage', new StorageMock());
  fetchMock = vi.fn();
  vi.stubGlobal('fetch', fetchMock);
});

describe('createOAuthClient', () => {
  it('creates a loopback client id from the requested scope', () => {
    const oauth = client();
    expect(oauth.redirectUri()).toBe('http://localhost:5173/callback');
    expect(oauth.clientId('atproto read')).toBe(
      'http://localhost?redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fcallback&scope=atproto+read',
    );
  });

  it('isolates and reads its configured session storage', () => {
    const oauth = client();
    localStorage.setItem(
      'oauth-client-test:session',
      JSON.stringify({ did: 'did:plc:alice' }),
    );
    localStorage.setItem(
      'other-app:session',
      JSON.stringify({ did: 'did:plc:other' }),
    );
    expect(oauth.currentSession()).toEqual({ did: 'did:plc:alice' });
  });

  it('stores the exact client id and scope used by a sign-in', async () => {
    fetchMock
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({ authorization_servers: ['https://auth.example'] }),
          { headers: { 'content-type': 'application/json' } },
        ),
      )
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            issuer: 'https://auth.example',
            pushed_authorization_request_endpoint:
              'https://auth.example/oauth/par',
            authorization_endpoint: 'https://auth.example/oauth/authorize',
            token_endpoint: 'https://auth.example/oauth/token',
          }),
          { headers: { 'content-type': 'application/json' } },
        ),
      )
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ request_uri: 'request-1' }), {
          headers: { 'content-type': 'application/json' },
        }),
      );

    const oauth = client();
    await expect(
      oauth.beginSignIn('alice.example', {
        scope: 'atproto read',
        returnTo: '/records',
      }),
    ).rejects.toThrow('navigation');

    const pending = JSON.parse(
      sessionStorage.getItem('oauth-client-test:pending') || '',
    );
    expect(pending.scope).toBe('atproto read');
    expect(pending.clientId).toBe(oauth.clientId('atproto read'));
    expect(pending.returnTo).toBe('/records');
  });
});