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
.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. | ||