diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index 08729a0e1..da57c07df 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -88,7 +88,8 @@ "rules": { "unicorn/prefer-number-properties": "off", "unicorn/prefer-export-from": "off", - "unicorn/no-array-reduce": "off" + "unicorn/no-array-reduce": "off", + "jsdoc/no-undefined-types": "error" }, "env": { "mocha": true diff --git a/packages/capabilities/src/access.js b/packages/capabilities/src/access.js index 46bd046c2..dc18d3dab 100644 --- a/packages/capabilities/src/access.js +++ b/packages/capabilities/src/access.js @@ -31,7 +31,6 @@ export const access = top.derive({ to: capability({ can: 'access/*', with: URI.match({ protocol: 'did:' }), - derives: equalWith, }), derives: equalWith, }) @@ -100,3 +99,12 @@ export const session = capability({ key: DID.match({ method: 'key' }), }, }) + +export const claim = base.derive({ + to: capability({ + can: 'access/claim', + with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })), + derives: equalWith, + }), + derives: equalWith, +}) diff --git a/packages/capabilities/test/capabilities/access.test.js b/packages/capabilities/test/capabilities/access.test.js index 383227337..c12f1e2c4 100644 --- a/packages/capabilities/test/capabilities/access.test.js +++ b/packages/capabilities/test/capabilities/access.test.js @@ -3,6 +3,8 @@ import { access } from '@ucanto/validator' import { Verifier } from '@ucanto/principal/ed25519' import * as Access from '../../src/access.js' import { alice, bob, service, mallory } from '../helpers/fixtures.js' +import * as Ucanto from '@ucanto/interface' +import { delegate, invoke } from '@ucanto/core' describe('access capabilities', function () { it('should self issue', async function () { @@ -222,4 +224,150 @@ describe('access capabilities', function () { }) }, /Expected a did:mailto: but got "did:NOT_MAILTO:web3.storage:test" instead/) }) + + describe('access/claim', () => { + // ensure we can use the capability to produce the invocations from the spec at https://github.com/web3-storage/specs/blob/576b988fb7cfa60049611963179277c420605842/w3-access.md + it('can create/access delegations from spec', async () => { + const audience = service.withDID('did:web:web3.storage') + /** + * @type {Array<(arg: { issuer: Ucanto.Signer>}) => Ucanto.IssuedInvocation>>} + */ + const examples = [ + // https://github.com/web3-storage/specs/blob/576b988fb7cfa60049611963179277c420605842/w3-access.md#accessclaim + ({ issuer }) => { + return Access.claim.invoke({ + issuer, + audience, + with: issuer.did(), + }) + }, + ] + for (const example of examples) { + const invocation = await example({ issuer: bob }).delegate() + const result = await access(invocation, { + capability: Access.claim, + principal: Verifier, + authority: audience, + }) + assert.ok( + result.error !== true, + 'result of access(invocation) is not an error' + ) + assert.deepEqual( + result.audience.did(), + audience.did(), + 'result audience did is expected value' + ) + assert.equal( + result.capability.can, + 'access/claim', + 'result capability.can is access/claim' + ) + assert.deepEqual(result.capability.nb, {}, 'result has empty nb') + } + }) + it('can be derived', async () => { + /** @type {Array} */ + const cansThatShouldDeriveAccessClaim = ['*', 'access/*'] + for (const can of cansThatShouldDeriveAccessClaim) { + const invocation = await invoke({ + issuer: alice, + audience: service, + capability: { + can: 'access/claim', + with: bob.did(), + }, + proofs: [ + await delegate({ + issuer: bob, + audience: alice, + capabilities: [ + { + can, + with: bob.did(), + }, + ], + }), + ], + }).delegate() + const result = await access(invocation, { + capability: Access.claim, + principal: Verifier, + authority: service, + }) + assert.ok( + result.error !== true, + 'result of access(invocation) is not an error' + ) + } + }) + it('cannot invoke when .with uses unexpected did method', async () => { + const issuer = bob.withDID('did:foo:bar') + assert.throws( + () => + Access.claim.invoke({ + issuer, + audience: service, + // @ts-ignore - expected complaint from compiler. We want to make sure there is an equivalent error at runtime + with: issuer.did(), + }), + `Invalid 'with'` + ) + }) + it('does not authorize invocations whose .with uses unexpected did methods', async () => { + const issuer = bob + const audience = service + const invocation = await delegate({ + issuer, + audience, + capabilities: [ + { + can: 'access/claim', + with: issuer.withDID('did:foo:bar').did(), + }, + ], + }) + const result = await access( + // @ts-ignore - expected complaint from compiler. We want to make sure there is an equivalent error at runtime + invocation, + { + capability: Access.claim, + principal: Verifier, + authority: audience, + } + ) + assert.ok(result.error, 'result of access(invocation) is an error') + assert.deepEqual(result.name, 'Unauthorized') + assert.ok( + result.delegationErrors.find((e) => + e.message.includes('but got "did:foo:bar" instead') + ), + 'a result.delegationErrors message mentions invalid with value' + ) + }) + it('does not authorize invocations whose .with is not an issuer in proofs', async () => { + const issuer = bob + const audience = service + const invocation = await Access.claim + .invoke({ + issuer, + audience, + // note: this did is not same as issuer.did() so issuer has no proof that they can use this resource + with: alice.did(), + }) + .delegate() + const result = await access(invocation, { + capability: Access.claim, + principal: Verifier, + authority: audience, + }) + assert.ok(result.error, 'result of access(invocation) is an error') + assert.deepEqual(result.name, 'Unauthorized') + assert.ok( + result.failedProofs.find((e) => { + return /Capability (.+) is not authorized/.test(e.message) + }) + ) + }) + }) })