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