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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
64 lines · 2.1 KB Raw History
// The DPoP handshake is a two-step: the server answers the first proof (which
// has no nonce) with `use_dpop_nonce` and a `DPoP-Nonce` header, and the client
// retries with that nonce. A browser client can only do this if it can READ the
// `DPoP-Nonce` header — which requires it to be listed in
// Access-Control-Expose-Headers. Without that, browser-based apps fail to log in
// on `use_dpop_nonce` while native apps (no CORS) work: this pins the header.

import { describe, expect, it } from 'vitest';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { createDpopProof, generateKeyPair } from './helpers/dpop.js';

const HOSTNAME = 'pds.example.com';

function createServer() {
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({}),
    sharedStorage: /** @type {any} */ ({
      checkAndStoreDpopJti: async () => true,
    }),
    blobs: /** @type {any} */ ({}),
    jwtSecret: 'test-secret',
    hostname: HOSTNAME,
  });
}

describe('DPoP nonce is readable by browser clients', () => {
  it('exposes DPoP-Nonce on the use_dpop_nonce response', async () => {
    const { privateKey, jwk } = await generateKeyPair();
    const url = `https://${HOSTNAME}/oauth/par`;
    // A valid DPoP proof with no nonce — the first step of the handshake.
    const proof = await createDpopProof({
      privateKey,
      jwk,
      method: 'POST',
      url,
      omitNonce: true,
    });

    const res = await createServer().fetch(
      new Request(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/x-www-form-urlencoded',
          DPoP: proof,
        },
        body: 'client_id=https://client.example/metadata.json',
      }),
    );

    expect(res.status).toBe(400);
    expect((await res.json()).error).toBe('use_dpop_nonce');

    // The nonce is present…
    expect(res.headers.get('DPoP-Nonce')).toBeTruthy();
    // …and a browser is allowed to read it, so it can retry.
    const expose = res.headers.get('Access-Control-Expose-Headers') || '';
    expect(
      expose
        .split(',')
        .map((h) => h.trim().toLowerCase())
        .includes('dpop-nonce'),
    ).toBe(true);
  });
});