// 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); }); });