import { base64UrlEncode } from '@pdsjs/core/crypto'; import { mintDpopNonce, renderConsentPage } from '@pdsjs/core/oauth'; import { PersonalDataServer } from '@pdsjs/core/pds'; import { describe, expect, it } from 'vitest'; const SPACE_SCOPE = 'atproto space:com.example.forum?authority=did:plc:aaaa&collection=com.example.post&manage=create'; describe('consent page with space scopes', () => { /** @param {string} scope */ const render = (scope) => renderConsentPage({ clientName: 'Test App', clientId: 'https://app.example/client-metadata.json', scope, requestUri: 'urn:ietf:params:oauth:request_uri:x', }); it('renders space grants as their own section', () => { const html = render(SPACE_SCOPE); expect(html).toContain('Private space permissions'); expect(html).toContain('com.example.forum'); expect(html).toContain('spaces under did:plc:aaaa'); expect(html).toContain('com.example.post'); expect(html).toContain('Manage spaces: create'); }); it('does not present a space grant as identity-only access', () => { // "Identity only" understates a grant that can read and write private // spaces; that copy is reserved for a bare `atproto` scope. const html = render('atproto space:com.example.forum'); expect(html).not.toContain('uniquely identify'); expect(html).toContain('wants to access your account'); }); it('keeps the identity-only copy for a bare atproto scope', () => { const html = render('atproto'); expect(html).toContain('uniquely identify'); }); it('labels self-authority grants and read_self plainly', () => { const html = render('atproto space:com.example.forum?action=read_self'); expect(html).toContain('your spaces'); expect(html).toContain('read own records'); }); }); describe('PAR space-scope gating', () => { /** * The smallest server able to reach the scope check in handleOAuthPar. * @param {boolean} spaces */ function makeServer(spaces) { /** @type {any[]} */ const stored = []; const pds = new PersonalDataServer( /** @type {any} */ ({ jwtSecret: 'test-secret', sharedStorage: { checkAndStoreDpopJti: async () => true, putOAuthRequest: async ( /** @type {string} */ uri, /** @type {any} */ data, ) => { stored.push({ uri, data }); }, }, ...(spaces ? { spaceStorage: {} } : {}), }), ); return { pds, stored }; } /** * A valid DPoP proof for POST /oauth/par, carrying the server nonce. * @param {string} htu */ async function dpopProof(htu) { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey); const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, }; const payload = { jti: crypto.randomUUID(), htm: 'POST', htu, iat: Math.floor(Date.now() / 1000), nonce: await mintDpopNonce('test-secret'), }; const enc = (/** @type {object} */ o) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(o))); const input = `${enc(header)}.${enc(payload)}`; const sig = new Uint8Array( await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, keys.privateKey, new TextEncoder().encode(input), ), ); return `${input}.${base64UrlEncode(sig)}`; } /** * @param {PersonalDataServer} pds * @param {string} scope */ async function par(pds, scope) { const url = new URL('https://pds.example/oauth/par'); const request = new Request(url, { method: 'POST', headers: { 'content-type': 'application/json', DPoP: await dpopProof('https://pds.example/oauth/par'), }, body: JSON.stringify({ client_id: 'http://localhost', // Loopback metadata registers the client_id itself as the redirect // origin, so the redirect must share it. redirect_uri: 'http://localhost/callback', response_type: 'code', code_challenge: base64UrlEncode( crypto.getRandomValues(new Uint8Array(32)), ), code_challenge_method: 'S256', scope, }), }); return pds._oauth.handleOAuthPar(request, url); } it('refuses a space: scope when spaces are disabled', async () => { const { pds, stored } = makeServer(false); const res = await par(pds, SPACE_SCOPE); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toBe('invalid_scope'); expect(stored).toHaveLength(0); }); it('accepts the same request when spaces are enabled', async () => { const { pds, stored } = makeServer(true); const res = await par(pds, SPACE_SCOPE); expect(res.status).toBe(201); expect(stored).toHaveLength(1); expect(stored[0].data.scope).toBe(SPACE_SCOPE); }); it('leaves non-space scopes alone when spaces are disabled', async () => { const { pds } = makeServer(false); const res = await par(pds, 'atproto repo:com.example.post'); expect(res.status).toBe(201); }); });