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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
143 lines · 4.5 KB Raw History
// The proofs this app mints are checked by the parser that will receive them,
// so a change to either side that breaks sign-in fails here first.

import { computeJwkThumbprint } from '@pdsjs/core/crypto';
import { parseDpopProof } from '@pdsjs/core/oauth';
import {
  base64Url,
  dpopProof,
  nonceFor,
  rememberNonce,
  thumbprint,
} from '@pdsjs/oauth-client/dpop';
import { describe, expect, it } from 'vitest';

/** @returns {Promise<CryptoKeyPair>} */
function keyPair() {
  return crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['sign', 'verify'],
  );
}

describe('base64Url', () => {
  it('drops the padding and uses the URL alphabet', () => {
    expect(base64Url(new Uint8Array([251, 255, 190]))).toBe('-_--');
    expect(base64Url(new Uint8Array([1]))).toBe('AQ');
  });
});

describe('thumbprint', () => {
  it('agrees with the one the server computes', async () => {
    const jwk = {
      crv: 'P-256',
      kty: 'EC',
      x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
      y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
    };
    expect(await thumbprint(jwk)).toBe(await computeJwkThumbprint(jwk));
  });

  // A JWK arrives with whatever members the exporter wrote and in whatever
  // order. RFC 7638 hashes four of them, sorted, and nothing else.
  it('ignores members outside the four the RFC names', async () => {
    const jwk = {
      kty: 'EC',
      crv: 'P-256',
      x: 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU',
      y: 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0',
    };
    expect(await thumbprint({ ...jwk, use: 'sig', alg: 'ES256' })).toBe(
      await thumbprint(jwk),
    );
  });
});

describe('dpopProof', () => {
  it('mints a proof the server-side parser accepts', async () => {
    const pair = await keyPair();
    const proof = await dpopProof({
      keyPair: pair,
      method: 'post',
      url: 'https://pds.example.com/oauth/par',
    });
    const parsed = await parseDpopProof(
      proof,
      'POST',
      'https://pds.example.com/oauth/par',
    );
    expect(parsed.jkt).toBe(await thumbprint(parsed.jwk));
  });

  it('leaves the query out of htu, which the RFC excludes', async () => {
    const pair = await keyPair();
    const proof = await dpopProof({
      keyPair: pair,
      method: 'GET',
      url: 'https://pds.example.com/xrpc/com.atproto.space.listRecords?space=x',
    });
    const payload = JSON.parse(atob(proof.split('.')[1]));
    expect(payload.htu).toBe(
      'https://pds.example.com/xrpc/com.atproto.space.listRecords',
    );
  });

  it('binds the proof to the token it presents', async () => {
    const pair = await keyPair();
    const token = 'an-access-token';
    const url = 'https://pds.example.com/xrpc/com.atproto.space.getRecord';
    const proof = await dpopProof({
      keyPair: pair,
      method: 'GET',
      url,
      token,
    });
    // The parser recomputes ath from the token and refuses a mismatch, so a
    // proof that passes here is one bound to this exact token.
    await expect(
      parseDpopProof(proof, 'GET', url, null, token),
    ).resolves.toBeTruthy();
    await expect(
      parseDpopProof(proof, 'GET', url, null, 'a-different-token'),
    ).rejects.toThrow(/ath/);
  });

  it('carries a nonce only when one is given', async () => {
    const pair = await keyPair();
    const bare = await dpopProof({
      keyPair: pair,
      method: 'POST',
      url: 'https://pds.example.com/oauth/token',
    });
    expect(JSON.parse(atob(bare.split('.')[1])).nonce).toBeUndefined();

    const withNonce = await dpopProof({
      keyPair: pair,
      method: 'POST',
      url: 'https://pds.example.com/oauth/token',
      nonce: 'abc',
    });
    expect(JSON.parse(atob(withNonce.split('.')[1])).nonce).toBe('abc');
  });
});

describe('the nonce store', () => {
  it('is keyed by origin, so one server never sends another its nonce', () => {
    rememberNonce(
      'https://one.example.com/oauth/par',
      new Response(null, { headers: { 'dpop-nonce': 'first' } }),
    );
    expect(nonceFor('https://one.example.com/oauth/token')).toBe('first');
    expect(nonceFor('https://two.example.com/oauth/token')).toBeUndefined();
  });

  it('keeps the last nonce when a response carries none', () => {
    rememberNonce(
      'https://three.example.com/a',
      new Response(null, { headers: { 'dpop-nonce': 'kept' } }),
    );
    rememberNonce('https://three.example.com/b', new Response(null));
    expect(nonceFor('https://three.example.com/c')).toBe('kept');
  });
});