Matthieu Sieben f9dc9aa4c9
Permission set (#4108)
* Export constants and type assertion utilities

* Add permission set support to oauth provider

* improve permission set parsing

* Rename `PermissionSet` to `ScopePermissions`

* Improve performance of NSID validation

* Add support for `permission-set` in lexicon document

* Validate NSID syntax using `@atproto/syntax`

* Export all types used in public interfaces (from `lexicon-resolver`)

* Small performance improvement

* Rework scope parsing utilities to work with Lexicon defined permissions

* file rename

* fixup! Rework scope parsing utilities to work with Lexicon defined permissions

* removed outdated comment

* removed outdated comment

* fix comment typo

* Improve `SimpleStore` api

* permission-set NSID auth scopes

* Remove dev dependency on dev-env

* fix build script

* pnpm-lock

* Improve fetch-node unicast protection

* Explicitly set the `redirect: "follow"` `fetch()` option

* Add delay when building oauth-provider-ui in watch mode

* Remove external dependencies from auth-scopes

* Add customizable lexicon authority to pds (for dev purposes)

* fix pds migration

* update permission-set icon

* Add support for `include:` syntax in scopes

* tidy

* Renaming of "resource" concept to better reflect the fact that not all oauth scope values are about resources

* changeset

* ui improvmeents

* i18n

* ui imporvements

* add `AtprotoAudience` type

* Enforce proper formatting of audience (atproto supported did + fragment part)

* tidy

* tidy

* tidy

* fix ci ?

* ci fix ?

* tidy ?

* Apply consistent outline around focusable items

* Use `inheritAud: true` to control `aud` inheritance

* Update packages/oauth/oauth-provider/src/lexicon/lexicon-manager.ts

Co-authored-by: devin ivy <devinivy@gmail.com>

* Review comments

* Add `nsid` property to `LexiconResolutionError`

* improve nsid validation

* i18n

* Improve oauth scope parsing

* Simplify lex scope parsing

* tidy

* docs

* tidy

* ci

* Code simplification

* tidy

* improve type safety

* improve deps graph

* naming

* Improve tests and package structure

* Improve error when resolving a non permission-set

* improve nsid parsing perfs

* benchmark

* Refactor ozone and lexicon into using a common service profile mechanism

* improve perfs

* ci fix (?)

* tidy

* Allow storage of valid lexicons in lexicon store

* Improve handling of lexicon resolution failures

* review comment

* Test both regexp and non regexp based nsid validation

* properly detect presence of port number in https did:web

* Re-enable logging of `safeFetch` requests

* tidy

---------

Co-authored-by: devin ivy <devinivy@gmail.com>
2025-08-29 12:19:19 +02:00

270 lines
8.6 KiB
TypeScript

import { SeedClient, TestNetworkNoAppView, usersSeed } from '@atproto/dev-env'
import { NSID } from '@atproto/syntax'
import {
AtprotoLexiconResolver,
buildLexiconResolver,
resolveLexiconDidAuthority,
} from '../src/index.js'
const dnsEntries: [entry: string, ...result: string[][]][] = []
jest.mock('node:dns/promises', () => {
return {
resolveTxt: (entry: string) => {
const found = dnsEntries.find(([e]) => e === entry)
if (found) return found.slice(1)
return []
},
}
})
describe('Lexicon resolution', () => {
let network: TestNetworkNoAppView
let sc: SeedClient
let resolveLexicon: AtprotoLexiconResolver
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'lex_lexicon_resolution',
})
sc = network.getSeedClient()
await usersSeed(sc)
dnsEntries.push(['_lexicon.alice.example', [`did=${sc.dids.alice}`]])
resolveLexicon = buildLexiconResolver({
rpc: { fetch },
idResolver: network.pds.ctx.idResolver,
})
})
afterAll(async () => {
jest.unmock('node:dns/promises')
await network.close()
})
it('resolves Lexicon.', async () => {
const client = network.pds.getClient()
const lex = await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.alice, rkey: 'example.alice.name1' },
{ id: 'example.alice.name1', lexicon: 1, defs: {} },
sc.getHeaders(sc.dids.alice),
)
const result = await resolveLexicon('example.alice.name1', {
forceRefresh: true,
})
expect(result.commit.did).toEqual(sc.dids.alice)
expect(result.cid.toString()).toEqual(lex.cid)
expect(result.uri.toString()).toEqual(lex.uri)
expect(result.nsid.toString()).toEqual('example.alice.name1')
expect(result.lexicon).toEqual({
$type: 'com.atproto.lexicon.schema',
id: 'example.alice.name1',
lexicon: 1,
defs: {},
})
})
it('fails on mismatched id.', async () => {
const client = network.pds.getClient()
await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.alice, rkey: 'example.alice.mismatch' },
{ id: 'example.test1.mismatch.bad', lexicon: 1, defs: {} },
sc.getHeaders(sc.dids.alice),
)
await expect(
resolveLexicon('example.alice.mismatch', {
forceRefresh: true,
}),
).rejects.toThrow(
'Lexicon schema record id (example.test1.mismatch.bad) does not match NSID (example.alice.mismatch)',
)
})
it('fails on missing DNS entry.', async () => {
const client = network.pds.getClient()
await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.bob, rkey: 'example.bob.name' },
{ id: 'example.bob.name', lexicon: 1, defs: {} },
sc.getHeaders(sc.dids.bob),
)
await expect(
resolveLexicon('example.bob.name', {
forceRefresh: true,
}),
).rejects.toThrow(
'Could not resolve a DID authority for NSID (example.bob.name)',
)
})
it('fails on missing record.', async () => {
await expect(
resolveLexicon('example.alice.missing', {
forceRefresh: true,
}),
).rejects.toThrow('Could not resolve Lexicon schema record')
})
it('fails on bad verification.', async () => {
const client = network.pds.getClient()
const alicekey = await network.pds.ctx.actorStore.keypair(sc.dids.alice)
const bobkey = await network.pds.ctx.actorStore.keypair(sc.dids.bob)
await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.alice, rkey: 'example.alice.badsig' },
{ id: 'example.alice.badsig', lexicon: 1, defs: {} },
sc.getHeaders(sc.dids.alice),
)
// switch alice's key away from the one used by her pds
await network.pds.ctx.plcClient.updateAtprotoKey(
sc.dids.alice,
network.pds.ctx.plcRotationKey,
bobkey.did(),
)
await expect(
resolveLexicon('example.alice.badsig', {
forceRefresh: true,
}),
).rejects.toThrow(
expect.objectContaining({
name: 'LexiconResolutionError',
message:
'Could not resolve Lexicon schema record (example.alice.badsig)',
cause: expect.objectContaining({
name: 'RecordResolutionError',
message: expect.stringContaining('Invalid signature on commit'),
}),
}),
)
// reset alice's key
await network.pds.ctx.plcClient.updateAtprotoKey(
sc.dids.alice,
network.pds.ctx.plcRotationKey,
alicekey.did(),
)
})
it('fails on invalid Lexicon document.', async () => {
const client = network.pds.getClient()
await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.alice, rkey: 'example.alice.baddoc' },
{ id: 'example.alice.baddoc', lexicon: 999, defs: {} },
sc.getHeaders(sc.dids.alice),
)
await expect(
resolveLexicon('example.alice.baddoc', {
forceRefresh: true,
}),
).rejects.toThrow(
expect.objectContaining({
name: 'LexiconResolutionError',
message: 'Invalid Lexicon document (example.alice.baddoc)',
cause: expect.objectContaining({
name: 'ZodError',
}),
}),
)
})
it('resolves Lexicon based on override authority.', async () => {
const client = network.pds.getClient()
await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.alice, rkey: 'example.alice.override' },
{
id: 'example.alice.override',
lexicon: 1,
defs: { alice: { type: 'string' } },
},
sc.getHeaders(sc.dids.alice),
)
const carolLex = await client.com.atproto.lexicon.schema.create(
{ repo: sc.dids.carol, rkey: 'example.alice.override' },
{
id: 'example.alice.override',
lexicon: 1,
defs: { carol: { type: 'string' } },
},
sc.getHeaders(sc.dids.carol),
)
const result = await resolveLexicon('example.alice.override', {
didAuthority: sc.dids.carol,
forceRefresh: true,
})
expect(result.commit.did).toEqual(sc.dids.carol)
expect(result.cid.toString()).toEqual(carolLex.cid)
expect(result.uri.toString()).toEqual(carolLex.uri)
expect(result.nsid.toString()).toEqual('example.alice.override')
expect(result.lexicon).toEqual({
$type: 'com.atproto.lexicon.schema',
id: 'example.alice.override',
lexicon: 1,
defs: { carol: { type: 'string' } },
})
})
describe('DID authority', () => {
it('handles a simple DNS resolution', async () => {
dnsEntries.push(['_lexicon.simple.test', ['did=did:example:simpleDid']])
const did = await resolveLexiconDidAuthority('test.simple.name')
expect(did).toBe('did:example:simpleDid')
})
it('handles a noisy DNS resolution', async () => {
dnsEntries.push([
'_lexicon.noisy.test',
['blah blah blah'],
['did:example:fakeDid'],
['atproto=did:example:fakeDid'],
['did=did:example:noisyDid'],
[
'chunk long domain aspdfoiuwerpoaisdfupasodfiuaspdfoiuasdpfoiausdfpaosidfuaspodifuaspdfoiuasdpfoiasudfpasodifuaspdofiuaspdfoiuasd',
'apsodfiuweproiasudfpoasidfu',
],
])
const did = await resolveLexiconDidAuthority('test.noisy.name')
expect(did).toBe('did:example:noisyDid')
})
it('handles a bad DNS resolution', async () => {
dnsEntries.push([
'_lexicon.bad.test',
['blah blah blah'],
['did:example:fakeDid'],
['atproto=did:example:fakeDid'],
[
'chunk long domain aspdfoiuwerpoaisdfupasodfiuaspdfoiuasdpfoiausdfpaosidfuaspodifuaspdfoiuasdpfoiasudfpasodifuaspdofiuaspdfoiuasd',
'apsodfiuweproiasudfpoasidfu',
],
])
const did = await resolveLexiconDidAuthority('test.bad.name')
expect(did).toBeUndefined()
})
it('throws on multiple dids under same domain', async () => {
dnsEntries.push([
'_lexicon.bad.test',
['did=did:example:firstDid'],
['did=did:example:secondDid'],
])
const did = await resolveLexiconDidAuthority('test.multi.name')
expect(did).toBeUndefined()
})
it('fails on invalid NSID', async () => {
await expect(resolveLexiconDidAuthority('not an nsid')).rejects.toThrow(
'Disallowed characters in NSID',
)
})
it('fails on invalid DID result', async () => {
dnsEntries.push(['_lexicon.invalid.test', ['did=not:a:did']])
const did = await resolveLexiconDidAuthority('test.invalid.name')
expect(did).toBeUndefined()
})
it('accepts NSID object', async () => {
const did = await resolveLexiconDidAuthority(
NSID.parse('test.simple.name'),
)
expect(did).toBe('did:example:simpleDid')
})
})
})