diff --git a/packages/access-api/package.json b/packages/access-api/package.json index 18f23566c..34053dc01 100644 --- a/packages/access-api/package.json +++ b/packages/access-api/package.json @@ -94,6 +94,7 @@ "error", { "definedTypes": [ + "AsyncIterable", "AsyncIterableIterator", "Awaited", "D1Database", diff --git a/packages/access-api/src/service/access-confirm.js b/packages/access-api/src/service/access-confirm.js index 3dd04b92a..9b04088b1 100644 --- a/packages/access-api/src/service/access-confirm.js +++ b/packages/access-api/src/service/access-confirm.js @@ -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 @@ -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, @@ -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 @@ -89,3 +82,40 @@ export async function handleAccessConfirm(invocation, ctx) { delegations: delegationsResponse.encode([delegation, attestation]), } } + +/** + * @param {Ucanto.Signer} service + * @param {Ucanto.Principal>} account + * @param {Ucanto.Principal>} agent + * @param {Ucanto.Capabilities} capabilities + * @param {AsyncIterable} 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] +} diff --git a/packages/access-api/test/access-client-agent.test.js b/packages/access-api/test/access-client-agent.test.js index 9b960cfae..0b0da17ab 100644 --- a/packages/access-api/test/access-client-agent.test.js +++ b/packages/access-api/test/access-client-agent.test.js @@ -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} */ ([ { @@ -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], + } + ), } })(), }, @@ -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>} */ + 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>} 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} */ ( + 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') } diff --git a/packages/access-api/test/helpers/ucanto-test-utils.js b/packages/access-api/test/helpers/ucanto-test-utils.js index 5163e3ae8..7d53c3acc 100644 --- a/packages/access-api/test/helpers/ucanto-test-utils.js +++ b/packages/access-api/test/helpers/ucanto-test-utils.js @@ -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} @@ -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 } } /** diff --git a/packages/access-api/test/helpers/utils.js b/packages/access-api/test/helpers/utils.js index e8bee3b09..494a9788e 100644 --- a/packages/access-api/test/helpers/utils.js +++ b/packages/access-api/test/helpers/utils.js @@ -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, 'push'>} storage + * @returns {Pick} + */ +export function createEmail(storage) { + const email = { + /** + * @param {ValidationEmailSend} email + */ + async sendValidation(email) { + storage.push(email) + }, + } + return email +} diff --git a/packages/access-api/test/provider-add.test.js b/packages/access-api/test/provider-add.test.js index 4411208da..d215e5d9c 100644 --- a/packages/access-api/test/provider-add.test.js +++ b/packages/access-api/test/provider-add.test.js @@ -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} */ ([ { @@ -273,23 +273,6 @@ for (const accessApiVariant of /** @type {const} */ ([ * @typedef {import('../src/utils/email.js').ValidationEmailSend} ValidationEmailSend */ -/** - * - * @param {Pick, 'push'>} storage - * @returns {Pick} - */ -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 diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 1a281233f..7659d3ff5 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -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 || []