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 {
expandPermissionSet,
expandScopeString,
parseIncludeScope,
parseScopesForDisplay,
permissionSetDef,
ScopePermissions,
} from '@pdsjs/core/scope';
import { describe, expect, it } from 'vitest';
const SET_NSID = 'social.grain.authFullApp';
const AUD = 'did:web:api.grain.social#grain';
/** A permission set exercising every containment and audience rule at once. */
const GRAIN_SET = {
lexicon: 1,
id: SET_NSID,
defs: {
main: {
type: 'permission-set',
title: 'Full grain access',
'title:fr': 'Accès complet à grain',
detail: 'Manage galleries, photos, and comments.',
permissions: [
{
type: 'permission',
resource: 'repo',
collection: ['social.grain.gallery', 'social.grain.photo'],
},
{
type: 'permission',
resource: 'repo',
collection: 'social.grain.comment',
action: ['create'],
},
{
type: 'permission',
resource: 'rpc',
inheritAud: true,
lxm: ['social.grain.actor.getProfile'],
},
// Outside the set's authority: must not be granted.
{
type: 'permission',
resource: 'repo',
collection: 'app.bsky.feed.post',
},
// Wildcards are never grantable from a set.
{ type: 'permission', resource: 'repo', collection: 'social.grain.*' },
// A concrete aud baked into a set must not be granted.
{
type: 'permission',
resource: 'rpc',
aud: 'did:web:evil.example#svc',
lxm: ['social.grain.feed.getTimeline'],
},
// Resources other than repo/rpc are not grantable from a set.
{ type: 'permission', resource: 'blob', accept: ['image/*'] },
],
},
},
};
const EXPANDED = [
'repo:social.grain.gallery',
'repo:social.grain.photo',
'repo:social.grain.comment?action=create',
`rpc:social.grain.actor.getProfile?aud=${encodeURIComponent(AUD)}`,
];
describe('parseIncludeScope', () => {
it('parses nsid and aud', () => {
expect(parseIncludeScope(`include:${SET_NSID}?aud=${AUD}`)).toEqual({
nsid: SET_NSID,
aud: AUD,
});
});
it('parses a bare include without aud', () => {
expect(parseIncludeScope(`include:${SET_NSID}`)).toEqual({
nsid: SET_NSID,
aud: null,
});
});
it('rejects non-include scopes, short names, wildcards, and bad auds', () => {
expect(parseIncludeScope('repo:social.grain.photo')).toBeNull();
expect(parseIncludeScope('include:grain')).toBeNull();
expect(parseIncludeScope('include:social.grain.*')).toBeNull();
expect(parseIncludeScope(`include:${SET_NSID}?aud=nonsense`)).toBeNull();
});
});
describe('permissionSetDef', () => {
it('accepts a permission-set main def', () => {
expect(permissionSetDef(GRAIN_SET)?.title).toBe('Full grain access');
});
it('rejects other lexicon shapes', () => {
expect(permissionSetDef(null)).toBeNull();
expect(permissionSetDef({})).toBeNull();
expect(permissionSetDef({ defs: { main: { type: 'record' } } })).toBeNull();
expect(
permissionSetDef({ defs: { main: { type: 'permission-set' } } }),
).toBeNull();
});
});
describe('expandPermissionSet', () => {
it('grants only contained, non-wildcard, audience-inherited permissions', () => {
expect(expandPermissionSet(SET_NSID, GRAIN_SET, AUD)).toEqual(EXPANDED);
});
it('drops rpc grants entirely when no aud is inherited', () => {
const scopes = expandPermissionSet(SET_NSID, GRAIN_SET, null);
expect(scopes).toEqual(EXPANDED.slice(0, 3));
});
it('returns null for a document that is not a permission set', () => {
expect(expandPermissionSet(SET_NSID, { defs: {} }, AUD)).toBeNull();
});
});
describe('expandPermissionSet with space permissions', () => {
const CAL_NSID = 'com.example.calendar.auth';
/** @param {object} permission */
const expandOne = (permission) =>
expandPermissionSet(CAL_NSID, {
lexicon: 1,
id: CAL_NSID,
defs: { main: { type: 'permission-set', permissions: [permission] } },
});
it('drops the query when the actions are the default four', () => {
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
action: ['read', 'create', 'update', 'delete'],
}),
).toEqual(['space:com.example.calendar.group']);
});
it('names the collections and a narrowed action list', () => {
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
collection: ['com.example.calendar.event'],
action: ['create', 'update'],
}),
).toEqual([
'space:com.example.calendar.group?collection=com.example.calendar.event&action=create&action=update',
]);
});
it('allows a collection under another authority, and a wildcard one', () => {
// The space type is authority-checked; its collections belong to that
// type's declaration, which another authority may own.
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
collection: ['app.bsky.feed.post'],
}),
).toEqual([
'space:com.example.calendar.group?collection=app.bsky.feed.post',
]);
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
collection: ['*'],
}),
).toEqual(['space:com.example.calendar.group?collection=*']);
});
it('refuses a space type outside the set authority, or a wildcard one', () => {
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'app.bsky.group',
}),
).toEqual([]);
expect(
expandOne({ type: 'permission', resource: 'space', spaceType: '*' }),
).toEqual([]);
});
it('refuses an action list this server cannot read', () => {
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
action: ['transmute'],
}),
).toEqual([]);
});
it('carries a set through for both its record actions and its manage verbs', () => {
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
manage: ['create', 'delete'],
action: ['read'],
}),
).toEqual([
'space:com.example.calendar.group?action=read&manage=create&manage=delete',
]);
});
it('drops a manage op this server cannot read, and keeps the actions', () => {
// An absent manage param grants no verb, so an unreadable list cannot
// widen the grant the way an unreadable action list would.
expect(
expandOne({
type: 'permission',
resource: 'space',
spaceType: 'com.example.calendar.group',
manage: ['transmute'],
action: ['read'],
}),
).toEqual(['space:com.example.calendar.group?action=read']);
});
// The set app.bulleted.spaceAccess publishes, verbatim. `authority=*` is the
// point of it: without that param the scope means authority=self, and a
// space another account hosts never matches.
it('carries authority=* through, so a shared space is reachable', () => {
const BULLETED_NSID = 'app.bulleted.spaceAccess';
const scopes = expandPermissionSet(BULLETED_NSID, {
lexicon: 1,
id: BULLETED_NSID,
defs: {
main: {
type: 'permission-set',
title: 'Bulleted spaces',
detail: 'Read the outlines shared in your Bulleted spaces.',
permissions: [
{
type: 'permission',
resource: 'space',
spaceType: 'app.bulleted.space',
authority: '*',
collection: [
'app.bulleted.node',
'app.bulleted.note',
'app.bulleted.outline',
'app.bulleted.mirror',
'app.bulleted.comment',
'app.bulleted.commentPolicy',
],
action: ['read', 'create', 'update', 'delete'],
},
],
},
},
});
expect(scopes).toEqual([
'space:app.bulleted.space?authority=*&collection=app.bulleted.node' +
'&collection=app.bulleted.note&collection=app.bulleted.outline' +
'&collection=app.bulleted.mirror&collection=app.bulleted.comment' +
'&collection=app.bulleted.commentPolicy',
]);
// The granted scope reaches a space the app's own account hosts.
const permissions = new ScopePermissions(
/** @type {string[]} */ (scopes).join(' '),
);
expect(
permissions.allowsSpace({
spaceType: 'app.bulleted.space',
authority: 'did:plc:iu5fzdrrfrc6kk7vmmatvin2',
skey: '3msw44754okhn',
action: 'read',
userDid: 'did:plc:bcgltzqazw5tb6k2g3ttenbj',
}),
).toBe(true);
});
// The set Bulletin publishes, verbatim. `manage` is the point of it: the app
// gates its own board-creation screen on a manage=create grant, and
// com.atproto.space.createSpace checks the same grant.
it('carries manage through, so a client can create its own space', () => {
const BULLETIN_NSID = 'my.bulletin.permissions';
const scopes = expandPermissionSet(BULLETIN_NSID, {
lexicon: 1,
id: BULLETIN_NSID,
defs: {
main: {
type: 'permission-set',
title: 'Bulletin',
detail: 'Read and post on Bulletin boards',
permissions: [
{
type: 'permission',
resource: 'space',
spaceType: 'my.bulletin.board',
authority: '*',
skey: 'self',
collection: [
'my.bulletin.post',
'my.bulletin.removal',
'my.bulletin.position',
],
action: ['read', 'create', 'update', 'delete'],
manage: ['create', 'update', 'delete'],
},
],
},
},
});
expect(scopes).toEqual([
'space:my.bulletin.board?authority=*&skey=self' +
'&collection=my.bulletin.post&collection=my.bulletin.removal' +
'&collection=my.bulletin.position' +
'&manage=create&manage=update&manage=delete',
]);
const permissions = new ScopePermissions(
/** @type {string[]} */ (scopes).join(' '),
);
const userDid = 'did:plc:bcgltzqazw5tb6k2g3ttenbj';
expect(
permissions.allowsSpaceManage({
spaceType: 'my.bulletin.board',
authority: userDid,
skey: 'self',
op: 'create',
userDid,
}),
).toBe(true);
expect(
permissions.allowsSpace({
spaceType: 'my.bulletin.board',
authority: userDid,
skey: 'self',
collection: 'my.bulletin.post',
action: 'create',
userDid,
}),
).toBe(true);
});
});
describe('expandScopeString', () => {
const scope = `atproto include:${SET_NSID}?aud=${AUD} repo:com.other.thing`;
it('replaces include tokens and keeps the rest verbatim', () => {
expect(
expandScopeString(scope, { [SET_NSID]: GRAIN_SET }).split(' '),
).toEqual(['atproto', ...EXPANDED, 'repo:com.other.thing']);
});
it('expands an unresolved include to nothing rather than something', () => {
expect(expandScopeString(scope, {})).toBe('atproto repo:com.other.thing');
});
it('falls back to atproto when nothing survives', () => {
expect(expandScopeString(`include:${SET_NSID}`, {})).toBe('atproto');
});
});
describe('parseScopesForDisplay additions', () => {
it('merges rpc grants by audience', () => {
const parsed = parseScopesForDisplay(
`rpc:com.a.one?aud=${AUD} rpc:com.a.two?aud=${AUD} rpc:com.b.x?aud=*`,
);
expect(parsed.rpcPermissions).toEqual([
{ aud: AUD, lxms: ['com.a.one', 'com.a.two'] },
{ aud: '*', lxms: ['com.b.x'] },
]);
});
it('lists include scopes', () => {
const parsed = parseScopesForDisplay(
`atproto include:${SET_NSID}?aud=${AUD}`,
);
expect(parsed.includeScopes).toEqual([{ nsid: SET_NSID, aud: AUD }]);
});
});
describe('legacy consent page with permission sets', () => {
it('shows the set title and detail', () => {
const html = renderConsentPage({
clientName: 'Test App',
clientId: 'https://app.example/client-metadata.json',
scope: `atproto include:${SET_NSID}?aud=${AUD}`,
requestUri: 'urn:ietf:params:oauth:request_uri:x',
permissionSets: { [SET_NSID]: GRAIN_SET },
});
expect(html).toContain('Full grain access');
expect(html).toContain('Manage galleries, photos, and comments.');
expect(html).not.toContain('uniquely identify');
});
it('falls back to the NSID when the set is missing', () => {
const html = renderConsentPage({
clientName: 'Test App',
clientId: 'https://app.example/client-metadata.json',
scope: `atproto include:${SET_NSID}`,
requestUri: 'urn:ietf:params:oauth:request_uri:x',
});
expect(html).toContain(SET_NSID);
});
});
describe('PAR include-scope resolution', () => {
/**
* The smallest server able to reach the scope checks in handleOAuthPar.
* @param {object|null} lexiconResolver
*/
function makeServer(lexiconResolver) {
/** @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 });
},
},
...(lexiconResolver ? { lexiconResolver } : {}),
}),
);
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',
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);
}
const resolver = {
assertValid: async () => 'valid',
getLexicon: async (/** @type {string} */ nsid) =>
nsid === SET_NSID ? GRAIN_SET : null,
};
const INCLUDE_SCOPE = `atproto include:${SET_NSID}?aud=${AUD}`;
it('stores the resolved permission set alongside the request', async () => {
const { pds, stored } = makeServer(resolver);
const res = await par(pds, INCLUDE_SCOPE);
expect(res.status).toBe(201);
expect(stored).toHaveLength(1);
expect(stored[0].data.scope).toBe(INCLUDE_SCOPE);
expect(stored[0].data.permissionSets).toEqual({ [SET_NSID]: GRAIN_SET });
});
it('refuses an include whose lexicon does not resolve', async () => {
const { pds, stored } = makeServer(resolver);
const res = await par(pds, 'atproto include:com.unknown.set');
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe('invalid_scope');
expect(stored).toHaveLength(0);
});
it('refuses an include that resolves to a non-permission-set lexicon', async () => {
const record = { defs: { main: { type: 'record' } } };
const { pds } = makeServer({
assertValid: async () => 'valid',
getLexicon: async () => record,
});
const res = await par(pds, INCLUDE_SCOPE);
expect(res.status).toBe(400);
});
it('refuses include scopes when no lexicon resolver is configured', async () => {
const { pds } = makeServer(null);
const res = await par(pds, INCLUDE_SCOPE);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe('invalid_scope');
});
it('leaves include-free scopes alone without a resolver', async () => {
const { pds } = makeServer(null);
const res = await par(pds, 'atproto repo:com.example.post');
expect(res.status).toBe(201);
});
});
describe('approval expands include scopes', () => {
it('freezes the expansion into the granted scope', async () => {
/** @type {any[]} */
const stored = [];
const pds = new PersonalDataServer(
/** @type {any} */ ({
jwtSecret: 'test-secret',
sharedStorage: {
putOAuthRequest: async (
/** @type {string} */ uri,
/** @type {any} */ data,
) => {
stored.push({ uri, data });
},
},
}),
);
/** @type {any} */ (pds).getDid = async () => 'did:plc:me';
const authRequest = /** @type {any} */ ({
clientId: 'http://localhost',
redirectUri: 'http://localhost/callback',
scope: `atproto include:${SET_NSID}?aud=${AUD}`,
permissionSets: { [SET_NSID]: GRAIN_SET },
codeChallenge: 'x',
codeChallengeMethod: 'S256',
dpopJkt: null,
});
const redirect = await pds._oauth.issueAuthorizationCode(
authRequest,
'urn:ietf:params:oauth:request_uri:x',
'https://pds.example',
);
expect(redirect).toContain('http://localhost/callback');
expect(authRequest.scope.split(' ')).toEqual(['atproto', ...EXPANDED]);
expect(authRequest.requestedScope).toBe(
`atproto include:${SET_NSID}?aud=${AUD}`,
);
// The stored request carries the expanded grant for the token exchange.
expect(stored[0].data.scope).toBe(authRequest.scope);
});
});