Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: test access-client-agent authorize #535

Merged
merged 10 commits into from
Mar 14, 2023
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