Skip to content

Commit

Permalink
feat: test access-client-agent authorize (#535)
Browse files Browse the repository at this point in the history
  • Loading branch information
gobengo committed Mar 14, 2023
1 parent f4ad371 commit 95a1c4a
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 45 deletions.
1 change: 1 addition & 0 deletions packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"error",
{
"definedTypes": [
"AsyncIterable",
"AsyncIterableIterator",
"Awaited",
"D1Database",
Expand Down
72 changes: 51 additions & 21 deletions packages/access-api/src/service/access-confirm.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as Ucanto from '@ucanto/interface'
import * as ucanto from '@ucanto/core'
import { Verifier, Absentee } from '@ucanto/principal'
import { Absentee } from '@ucanto/principal'
import { collect } from 'streaming-iterables'
import * as Access from '@web3-storage/capabilities/access'
import { delegationsToString } from '@web3-storage/access/encoding'
import * as delegationsResponse from '../utils/delegations-response.js'
import * as validator from '@ucanto/validator'

/**
* @typedef {import('@web3-storage/capabilities/types').AccessConfirmSuccess} AccessConfirmSuccess
Expand All @@ -18,7 +19,9 @@ export function parse(invocation) {
const capability = invocation.capabilities[0]
// Create a absentee signer for the account that authorized the delegation
const account = Absentee.from({ id: capability.nb.iss })
const agent = Verifier.parse(capability.nb.aud)
const agent = {
did: () => validator.DID.match({ method: 'key' }).from(capability.nb.aud),
}
return {
account,
agent,
Expand Down Expand Up @@ -50,31 +53,21 @@ export async function handleAccessConfirm(invocation, ctx) {
}))
)

// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: account,
audience: agent,
const [delegation, attestation] = await createSessionProofs(
ctx.signer,
account,
agent,
capabilities,
expiration: Infinity,
// We include all the delegations to the account so that the agent will
// have delegation chains to all the delegated resources.
// We should actually filter out only delegations that support delegated
// capabilities, but for now we just include all of them since we only
// implement sudo access anyway.
proofs: await collect(
ctx.models.delegations.find({
audience: account.did(),
})
),
})

const attestation = await Access.session.delegate({
issuer: ctx.signer,
audience: agent,
with: ctx.signer.did(),
nb: { proof: delegation.cid },
expiration: Infinity,
})
ctx.models.delegations.find({
audience: account.did(),
}),
Infinity
)

// Store the delegations so that they can be pulled with access/claim
// The fact that we're storing proofs chains that we pulled from the
Expand All @@ -89,3 +82,40 @@ export async function handleAccessConfirm(invocation, ctx) {
delegations: delegationsResponse.encode([delegation, attestation]),
}
}

/**
* @param {Ucanto.Signer} service
* @param {Ucanto.Principal<Ucanto.DID<'mailto'>>} account
* @param {Ucanto.Principal<Ucanto.DID<'key'>>} agent
* @param {Ucanto.Capabilities} capabilities
* @param {AsyncIterable<Ucanto.Delegation>} delegationProofs
* @param {number} expiration
* @returns {Promise<[delegation: Ucanto.Delegation, attestation: Ucanto.Delegation]>}
*/
export async function createSessionProofs(
service,
account,
agent,
capabilities,
delegationProofs,
expiration
) {
// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: Absentee.from({ id: account.did() }),
audience: agent,
capabilities,
expiration,
proofs: [...(await collect(delegationProofs))],
})

const attestation = await Access.session.delegate({
issuer: service,
audience: agent,
with: service.did(),
nb: { proof: delegation.cid },
expiration,
})

return [delegation, attestation]
}
122 changes: 118 additions & 4 deletions packages/access-api/test/access-client-agent.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* eslint-disable no-console */
import { context } from './helpers/context.js'
import { createTesterFromContext } from './helpers/ucanto-test-utils.js'
import * as principal from '@ucanto/principal'
import { Agent as AccessAgent } from '@web3-storage/access/agent'
import * as w3caps from '@web3-storage/capabilities'
import * as assert from 'assert'
import * as Ucanto from '@ucanto/interface'
import { createEmail } from './helpers/utils.js'
import { stringToDelegations } from '@web3-storage/access/encoding'
import * as delegationsResponse from '../src/utils/delegations-response.js'

for (const accessApiVariant of /** @type {const} */ ([
{
Expand All @@ -12,12 +18,24 @@ for (const accessApiVariant of /** @type {const} */ ([
did: () => /** @type {const} */ ('did:mailto:dag.house:foo'),
}
const spaceWithStorageProvider = principal.ed25519.generate()
/** @type {{to:string, url:string}[]} */
const emails = []
const email = createEmail(emails)
return {
spaceWithStorageProvider,
...createTesterFromContext(context, {
account,
registerSpaces: [spaceWithStorageProvider],
}),
emails,
...createTesterFromContext(
() =>
context({
globals: {
email,
},
}),
{
account,
registerSpaces: [spaceWithStorageProvider],
}
),
}
})(),
},
Expand All @@ -31,5 +49,101 @@ for (const accessApiVariant of /** @type {const} */ ([
const delegations = accessAgent.proofs()
assert.equal(space.proof.cid, delegations[0].cid)
})
it.skip('can authorize', async () => {
const accessAgent = await AccessAgent.create(undefined, {
connection: await accessApiVariant.connection,
})
await accessAgent.authorize('example@dag.house')
})

it('can be used to do session authorization', async () => {
const { emails, connection, service } = accessApiVariant
const accessAgent = await AccessAgent.create(undefined, {
connection: await connection,
})
/** @type {Ucanto.Principal<Ucanto.DID<'mailto'>>} */
const account = { did: () => 'did:mailto:dag.house:example' }
await testSessionAuthorization(
await service,
accessAgent,
account,
emails
)
})
})
}

/**
* @typedef {import('./provider-add.test.js').AccessAuthorize} AccessAuthorize
* @typedef {import('@web3-storage/capabilities/src/types.js').AccessConfirm} AccessConfirm
* @typedef {import('./helpers/ucanto-test-utils.js').AccessService} AccessService
*/

/**
* @param {principal.ed25519.Signer.Signer<`did:web:${string}`, principal.ed25519.Signer.UCAN.SigAlg>} service
* @param {AccessAgent} access
* @param {Ucanto.Principal<Ucanto.DID<'mailto'>>} account
* @param {{to:string, url:string}[]} emails
*/
async function testSessionAuthorization(service, access, account, emails) {
const authorizeResult = await access.invokeAndExecute(
w3caps.Access.authorize,
{
audience: access.connection.id,
with: access.issuer.did(),
nb: {
iss: account.did(),
att: [{ can: '*' }],
},
}
)
assert.notDeepStrictEqual(
authorizeResult.error,
true,
'authorize result is not an error'
)

const latestEmail = emails.at(-1)
assert.ok(latestEmail, 'received a confirmation email')
const confirmationInvocations = stringToDelegations(
new URL(latestEmail.url).searchParams.get('ucan') ?? ''
)
assert.deepEqual(confirmationInvocations.length, 1)
const serviceSaysAccountCanConfirm =
/** @type {Ucanto.Invocation<import('@web3-storage/capabilities/src/types.js').AccessConfirm>} */ (
confirmationInvocations[0]
)

const confirm = await w3caps.Access.confirm
.invoke({
nb: {
...serviceSaysAccountCanConfirm.capabilities[0].nb,
},
issuer: service,
audience: access.connection.id,
with: access.connection.id.did(),
proofs: [serviceSaysAccountCanConfirm],
})
.delegate()

const [confirmationResult] = await access.connection.execute(confirm)
assert.notDeepStrictEqual(
confirmationResult.error,
true,
'confirm result is not an error'
)

const claimResult = await access.invokeAndExecute(w3caps.Access.claim, {
with: access.issuer.did(),
})
assert.notDeepEqual(
claimResult.error,
true,
'access/claim result is not an error'
)
assert.ok(!claimResult.error)
const claimedDelegations1 = [
...delegationsResponse.decode(claimResult.delegations),
]
assert.ok(claimedDelegations1.length > 0, 'claimed some delegations')
}
3 changes: 2 additions & 1 deletion packages/access-api/test/helpers/ucanto-test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function createTesterFromContext(createContext, options) {
const connection = context.then((ctx) => ctx.conn)
const issuer = context.then(({ issuer }) => issuer)
const audience = context.then(({ service }) => service)
const service = context.then(({ service }) => service)
const miniflare = context.then(({ mf }) => mf)
/**
* @type {import('../../src/types/ucanto').ServiceInvoke<Service>}
Expand All @@ -44,7 +45,7 @@ export function createTesterFromContext(createContext, options) {
const [result] = await conn.execute(invocation)
return result
}
return { issuer, audience, invoke, miniflare, context, connection }
return { issuer, audience, invoke, miniflare, context, connection, service }
}

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/access-api/test/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,26 @@ export async function createSpace(issuer, service, conn, email) {
export function isUploadApiStack(stack) {
return stack.includes('file:///var/task/upload-api')
}

/**
* @typedef {import('../../src/utils/email').ValidationEmailSend} ValidationEmailSend
* @typedef {import('../../src/utils/email').Email} Email
*/

/**
* create an Email that is useful for testing
*
* @param {Pick<Array<ValidationEmailSend>, 'push'>} storage
* @returns {Pick<Email, 'sendValidation'>}
*/
export function createEmail(storage) {
const email = {
/**
* @param {ValidationEmailSend} email
*/
async sendValidation(email) {
storage.push(email)
},
}
return email
}
19 changes: 1 addition & 18 deletions packages/access-api/test/provider-add.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import * as Ucanto from '@ucanto/interface'
import { Access, Provider } from '@web3-storage/capabilities'
import * as delegationsResponse from '../src/utils/delegations-response.js'
import { createProvisions } from '../src/models/provisions.js'
import { Email } from '../src/utils/email.js'
import { NON_STANDARD } from '@ipld/dag-ucan/signature'
import { createEmail } from './helpers/utils.js'

for (const providerAddHandlerVariant of /** @type {const} */ ([
{
Expand Down Expand Up @@ -273,23 +273,6 @@ for (const accessApiVariant of /** @type {const} */ ([
* @typedef {import('../src/utils/email.js').ValidationEmailSend} ValidationEmailSend
*/

/**
*
* @param {Pick<Array<ValidationEmailSend>, 'push'>} storage
* @returns {Pick<Email, 'sendValidation'>}
*/
export function createEmail(storage) {
const email = {
/**
* @param {ValidationEmailSend} email
*/
async sendValidation(email) {
storage.push(email)
},
}
return email
}

/**
* @typedef {import('@web3-storage/capabilities/types').AccessClaim} AccessClaim
* @typedef {import('@web3-storage/capabilities/types').AccessAuthorize} AccessAuthorize
Expand Down
4 changes: 3 additions & 1 deletion packages/access-client/src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,9 @@ export class Agent {
async invoke(cap, options) {
const space = options.with || this.currentSpace()
if (!space) {
throw new Error('No space selected, you need pass a resource.')
throw new Error(
'No space or resource selected, you need pass a resource.'
)
}

const extraProofs = options.proofs || []
Expand Down

0 comments on commit 95a1c4a

Please sign in to comment.