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