chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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);
});
});