~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0

A space scope that names its type alone is granted the type's collections

Merged chadtmiller.com asked to merge into main from chadtmiller.com/pds.js:fix/expand-space-scopes
.changeset/expand-space-scopes.mdnew+10−0
@@ -0,0 +1,10 @@
---
'@pdsjs/core': patch
---
A `space:` scope that names a space type without a `collection` is granted
the collections that type's lexicon declares, as the reference PDS does.
The lexicon is resolved when the authorization request arrives, and approval
writes the collections into the granted scope. A type whose lexicon does not
resolve to a space is granted as asked. Before, every such grant permitted
no write in the space.
packages/core/src/handlers/oauth.js+46−0
@@ -24,12 +24,15 @@ import {
} from '../oauth.js';
import { PASSKEY_COOKIE } from '../passkey.js';
import {
bareSpaceTypes,
expandPermissionSet,
expandScopeString,
expandSpaceScopes,
parseIncludeScope,
parseScopesForDisplay,
parseSpaceScope,
permissionSetDef,
spaceTypeCollections,
} from '../scope.js';
import { verifyAssertion } from '../webauthn.js';
@@ -59,6 +62,8 @@ import { verifyAssertion } from '../webauthn.js';
* lexicon documents for the request's include: scopes, keyed by NSID
* @property {string} [requestedScope] - The client's original scope string,
* kept when approval expands include: scopes into concrete grants
* @property {Record<string, string[]>} [spaceTypes] - The collections each
* space type declares, for the request's space: scopes that name none
*/
/**
@@ -453,6 +458,32 @@ export function createOAuthHandlers(ctx) {
return { permissionSets };
}
/**
* Resolve the lexicon of each space type that a space: scope names without
* a collection. Approval writes the declared collections into the scope.
* A type that does not resolve to a space is left out, and its scope is
* granted as asked.
* @param {string} scope - Space-separated scope string
* @returns {Promise<Record<string, string[]>|null>}
*/
async function resolveSpaceTypes(scope) {
const getLexicon = lexiconResolver?.getLexicon?.bind(lexiconResolver);
if (!getLexicon) return null;
/** @type {Record<string, string[]>} */
const spaceTypes = {};
for (const type of bareSpaceTypes(scope)) {
let doc = null;
try {
doc = await getLexicon(type);
} catch {
// A type that fails to resolve is granted as asked.
}
const collections = spaceTypeCollections(doc);
if (collections) spaceTypes[type] = collections;
}
return Object.keys(spaceTypes).length > 0 ? spaceTypes : null;
}
/**
* Authenticate an OAuth client for the token/PAR endpoints, per its
* `token_endpoint_auth_method`. Public clients (`none`) need nothing here —
@@ -678,6 +709,8 @@ export function createOAuthHandlers(ctx) {
const setsResult = await resolvePermissionSets(data.scope || 'atproto');
if ('error' in setsResult) return setsResult.error;
const spaceTypes = await resolveSpaceTypes(data.scope || 'atproto');
const requestId = crypto.randomUUID();
const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
const expiresIn = 600;
@@ -693,6 +726,7 @@ export function createOAuthHandlers(ctx) {
...(setsResult.permissionSets && {
permissionSets: setsResult.permissionSets,
}),
...(spaceTypes && { spaceTypes }),
state: data.state,
responseMode: data.response_mode,
loginHint: data.login_hint,
@@ -795,6 +829,10 @@ export function createOAuthHandlers(ctx) {
);
if ('error' in setsResult) return setsResult.error;
const spaceTypes = await resolveSpaceTypes(
url.searchParams.get('scope') || 'atproto',
);
const requestId = crypto.randomUUID();
const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`;
const expiresAt = Date.now() + 600000;
@@ -809,6 +847,7 @@ export function createOAuthHandlers(ctx) {
...(setsResult.permissionSets && {
permissionSets: setsResult.permissionSets,
}),
...(spaceTypes && { spaceTypes }),
state: url.searchParams.get('state'),
responseMode: url.searchParams.get('response_mode'),
loginHint: url.searchParams.get('login_hint'),
@@ -973,6 +1012,13 @@ export function createOAuthHandlers(ctx) {
authRequest.permissionSets,
);
}
if (authRequest.spaceTypes) {
authRequest.requestedScope ??= authRequest.scope;
authRequest.scope = expandSpaceScopes(
authRequest.scope || 'atproto',
authRequest.spaceTypes,
);
}
// Update request with code
authRequest.code = code;
packages/core/src/scope.js+58−0
@@ -421,6 +421,64 @@ export function parseSpaceScope(scope) {
return { spaceType, authority, skey, collections, actions, manage };
}
/**
* The collections a resolved lexicon document declares for its space type,
* or null when the document does not define a space type.
* @param {object|null|undefined} doc - A lexicon document (defs.main expected)
* @returns {string[] | null}
*/
export function spaceTypeCollections(doc) {
const container = /** @type {{ defs?: Record<string, unknown> }} */ (
doc ?? {}
);
const main =
/** @type {{ type?: unknown, collections?: unknown } | undefined} */ (
container.defs?.main
);
if (main?.type !== 'space') return null;
return toStringArray(main.collections).filter(
(c) => c !== '*' && c.includes('.'),
);
}
/**
* The space types that a scope string names without a `collection`.
* @param {string} scope - Space-separated scope string
* @returns {string[]}
*/
export function bareSpaceTypes(scope) {
const types = new Set();
for (const s of scope.split(' ')) {
const grant = parseSpaceScope(s);
if (grant && grant.spaceType !== '*' && !grant.collections) {
types.add(grant.spaceType);
}
}
return [...types];
}
/**
* Write each space type's declared collections into the `space:` scopes that
* name the type without a `collection`. A write check has no lexicon to read,
* and a grant with no collections permits no write.
* @param {string} scope - Space-separated scope string
* @param {Record<string, string[]>} spaceTypes - Declared collections by type
* @returns {string}
*/
export function expandSpaceScopes(scope, spaceTypes) {
return scope
.split(' ')
.map((s) => {
const grant = parseSpaceScope(s);
if (!grant || grant.spaceType === '*' || grant.collections) return s;
const collections = spaceTypes[grant.spaceType] ?? [];
if (collections.length === 0) return s;
const query = collections.map((c) => `collection=${c}`).join('&');
return `${s}${s.includes('?') ? '&' : '?'}${query}`;
})
.join(' ');
}
/**
* Whether a grant covers a request against a space.
*
packages/core/test/space-scope.test.js+65−0
@@ -1,9 +1,12 @@
import {
bareSpaceTypes,
expandSpaceScopes,
parseScopesForDisplay,
parseSpaceScope,
ScopePermissions,
spaceScopeAllowsManage,
spaceScopeMatches,
spaceTypeCollections,
} from '@pdsjs/core/scope';
import { describe, expect, it } from 'vitest';
@@ -290,3 +293,65 @@ describe('parseScopesForDisplay', () => {
expect(display.spacePermissions[0].actions).toEqual(['read']);
});
});
describe('space types without collections', () => {
const FORUM = {
lexicon: 1,
id: 'com.example.forum',
defs: {
main: {
type: 'space',
collections: ['com.example.post', 'com.example.reply', '*', 'nodots'],
},
},
};
it('reads the collections a space type declares', () => {
expect(spaceTypeCollections(FORUM)).toEqual([
'com.example.post',
'com.example.reply',
]);
expect(spaceTypeCollections({ defs: { main: { type: 'space' } } })).toEqual(
[],
);
expect(
spaceTypeCollections({ defs: { main: { type: 'record' } } }),
).toBeNull();
expect(spaceTypeCollections(null)).toBeNull();
});
it('names the space types a scope grants without a collection', () => {
expect(
bareSpaceTypes(
'atproto space:com.example.forum?authority=* space:com.example.forum ' +
'space:com.example.chat?collection=com.example.msg space:* repo:com.example.post',
),
).toEqual(['com.example.forum']);
});
it('writes the declared collections into those scopes', () => {
const scope = expandSpaceScopes(
'atproto space:com.example.forum?authority=*&manage=create ' +
'space:com.example.forum space:com.example.chat?collection=com.example.msg ' +
'space:com.example.empty',
{
'com.example.forum': ['com.example.post', 'com.example.reply'],
'com.example.empty': [],
},
);
expect(scope.split(' ')).toEqual([
'atproto',
'space:com.example.forum?authority=*&manage=create&collection=com.example.post&collection=com.example.reply',
'space:com.example.forum?collection=com.example.post&collection=com.example.reply',
'space:com.example.chat?collection=com.example.msg',
'space:com.example.empty',
]);
const grant = parse(scope.split(' ')[1]);
expect(grant.collections).toEqual([
'com.example.post',
'com.example.reply',
]);
expect(grant.authority).toBe('*');
expect(grant.manage).toEqual(['create']);
});
});
test/oauth-endpoints.test.js+111−2
@@ -21,7 +21,7 @@ const REDIRECT_URI = 'http://localhost/callback';
/**
* @param {{
* readOnly?: boolean, hasAccountApp?: boolean, passkeys?: boolean,
* did?: string|null, signingKey?: boolean,
* did?: string|null, signingKey?: boolean, spacesEnabled?: boolean,
* lexiconResolver?: import('../packages/core/src/ports.js').LexiconResolverPort,
* }} [options]
*/
@@ -31,6 +31,7 @@ function createServer({
passkeys = false,
did = DID,
signingKey = true,
spacesEnabled = false,
lexiconResolver = undefined,
} = {}) {
/** @type {Map<string, any>} */
@@ -82,7 +83,7 @@ function createServer({
verifyAccountPassword: async (candidate) => candidate === PASSWORD,
passwordConfigured: async () => true,
readOnly,
spacesEnabled: false,
spacesEnabled,
hasAccountApp,
lexiconResolver,
getDid: async () => did,
@@ -594,6 +595,114 @@ describe('the authorization flow', () => {
});
});
describe('space scopes that name a type alone', () => {
const FORUM = 'com.example.forum';
const forum = {
lexicon: 1,
id: FORUM,
defs: {
main: {
type: 'space',
collections: ['com.example.post', 'com.example.reply'],
},
},
};
/** @type {import('../packages/core/src/ports.js').LexiconResolverPort} */
const lexiconResolver = {
assertValid: async () => /** @type {'valid'} */ ('valid'),
getLexicon: async (nsid) => (nsid === FORUM ? forum : null),
};
it('grants the collections the type declares', async () => {
const { handlers, oauthTokens } = createServer({
spacesEnabled: true,
lexiconResolver,
});
const client = await dpopKeys();
const { verifier, challenge } = await pkcePair();
const requestUri = await par(handlers, client, challenge, {
scope: `atproto space:${FORUM}?authority=*&manage=create`,
});
const code = await approve(handlers, requestUri);
const response = await token(handlers, client, {
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
});
expect(response.status).toBe(200);
const body = await response.json();
const granted = `atproto space:${FORUM}?authority=*&manage=create&collection=com.example.post&collection=com.example.reply`;
expect(body.scope).toBe(granted);
expect(oauthTokens.get(body.refresh_token).scope).toBe(granted);
});
it('grants a type that does not resolve to a space as asked', async () => {
const { handlers, oauthRequests } = createServer({
spacesEnabled: true,
lexiconResolver: {
...lexiconResolver,
getLexicon: async (nsid) => {
if (nsid === 'com.example.broken') throw new Error('unreachable');
return nsid === FORUM ? forum : null;
},
},
});
const client = await dpopKeys();
const { challenge } = await pkcePair();
const scope = `atproto space:com.example.unknown space:com.example.broken space:${FORUM}`;
const requestUri = await par(handlers, client, challenge, { scope });
await approve(handlers, requestUri);
expect(oauthRequests.get(requestUri).scope).toBe(
`atproto space:com.example.unknown space:com.example.broken space:${FORUM}?collection=com.example.post&collection=com.example.reply`,
);
});
it('grants every type as asked with no resolver, on the direct path', async () => {
const { handlers, oauthRequests } = createServer({ spacesEnabled: true });
const response = await call(handlers, '/oauth/authorize', {
search: {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
code_challenge: 'c'.repeat(43),
code_challenge_method: 'S256',
scope: `atproto space:${FORUM}`,
},
});
expect(response.status).toBe(200);
const [stored] = [...oauthRequests.values()];
expect(stored.scope).toBe(`atproto space:${FORUM}`);
expect(stored.spaceTypes).toBeUndefined();
});
it('grants the declared collections on the direct path', async () => {
const { handlers, oauthRequests } = createServer({
spacesEnabled: true,
lexiconResolver,
});
await call(handlers, '/oauth/authorize', {
search: {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
code_challenge: 'c'.repeat(43),
code_challenge_method: 'S256',
scope: `atproto space:${FORUM}`,
},
});
const [stored] = [...oauthRequests.values()];
expect(stored.spaceTypes).toEqual({
[FORUM]: ['com.example.post', 'com.example.reply'],
});
});
});
// RFC 9449 §10.1 lets a pushed request carry the key thumbprint in dpop_jkt
// instead of a proof, and the reference authorization server accepts a request
// that carries neither. Clients in the wild send both shapes.