From a466a7de4e5b3d9c727307dd781f5e5c9b7cdf0a Mon Sep 17 00:00:00 2001 From: Benjamin Goering <171782+gobengo@users.noreply.github.com> Date: Tue, 28 Feb 2023 13:24:02 -0800 Subject: [PATCH] feat: access/authorize confirmation email click results in a delegation back to the issuer did:key so that access/claim works (#460) Motivation: * #455 * This implements the second delegation described in https://github.com/web3-storage/w3protocol/issues/457 * make this test pass once we deploy this to staging https://github.com/gobengo/w3protocol-test/pull/6 Test Case: * https://github.com/gobengo/w3protocol-test/pull/6/files#diff-698e92d7467a4a470689d4cb120272c6cac28864d4960ac12a3720ab7c35a15cR364 --------- Co-authored-by: Irakli Gozalishvili --- packages/access-api/src/models/delegations.js | 2 +- .../access-api/src/routes/validate-email.js | 44 +++++++++ .../access-api/src/service/access-claim.js | 4 - packages/access-api/src/types/delegations.ts | 2 +- .../access-api/test/access-authorize.test.js | 99 ++++++++++++++++++- .../access-api/test/access-delegate.test.js | 37 +++++++ .../access-api/test/voucher-claim.test.js | 43 +++++++- 7 files changed, 223 insertions(+), 8 deletions(-) diff --git a/packages/access-api/src/models/delegations.js b/packages/access-api/src/models/delegations.js index bc693028f..8645c5cf4 100644 --- a/packages/access-api/src/models/delegations.js +++ b/packages/access-api/src/models/delegations.js @@ -124,7 +124,7 @@ function createDelegationRowUpdate(d) { /** * @param {DelegationsDatabase} db - * @param {Ucanto.DID<'key'>} audience + * @param {Ucanto.DID} audience */ async function selectByAudience(db, audience) { return await db diff --git a/packages/access-api/src/routes/validate-email.js b/packages/access-api/src/routes/validate-email.js index bb91b696f..f7da3c988 100644 --- a/packages/access-api/src/routes/validate-email.js +++ b/packages/access-api/src/routes/validate-email.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-vars */ import { stringToDelegation } from '@web3-storage/access/encoding' +import * as Access from '@web3-storage/capabilities/access' import QRCode from 'qrcode' import { toEmail } from '../utils/did-mailto.js' import { @@ -8,6 +9,9 @@ import { ValidateEmailError, PendingValidateEmail, } from '../utils/html.js' +import * as ucanto from '@ucanto/core' +import * as validator from '@ucanto/validator' +import { Verifier } from '@ucanto/principal/ed25519' /** * @param {import('@web3-storage/worker-utils/router').ParsedRequest} req @@ -134,6 +138,46 @@ async function session(req, env) { req.query.ucan, delegation.capabilities[0].nb.key ) + if (req.method.toLowerCase() === 'post') { + const accessSessionResult = await validator.access(delegation, { + capability: Access.session, + principal: Verifier, + authority: env.signer, + }) + if (accessSessionResult.error) { + throw new Error( + `unable to validate access session: ${accessSessionResult.error}` + ) + } + const account = accessSessionResult.audience + const agentPubkey = accessSessionResult.capability.nb.key + const wrappedKeyCanAsignForAccount = await ucanto.delegate({ + issuer: env.signer, + audience: { did: () => agentPubkey }, + capabilities: [ + { + with: env.signer.did(), + can: 'access-api/delegation', + }, + ], + proofs: [ + await ucanto.delegate({ + issuer: env.signer, + audience: account, + capabilities: [ + { + with: env.signer.did(), + can: './update', + nb: { + key: agentPubkey, + }, + }, + ], + }), + ], + }) + await env.models.delegations.putMany(wrappedKeyCanAsignForAccount) + } try { return new HtmlResponse( diff --git a/packages/access-api/src/service/access-claim.js b/packages/access-api/src/service/access-claim.js index 5bfd6ec85..e11d28186 100644 --- a/packages/access-api/src/service/access-claim.js +++ b/packages/access-api/src/service/access-claim.js @@ -1,7 +1,6 @@ import * as Server from '@ucanto/server' import { claim } from '@web3-storage/capabilities/access' import * as Ucanto from '@ucanto/interface' -import * as validator from '@ucanto/validator' import * as delegationsResponse from '../utils/delegations-response.js' import { collect } from 'streaming-iterables' @@ -41,9 +40,6 @@ export function createAccessClaimHandler({ delegations }) { /** @type {AccessClaimHandler} */ return async (invocation) => { const claimedAudience = invocation.capabilities[0].with - if (validator.DID.match({ method: 'mailto' }).is(claimedAudience)) { - throw new Error(`did:mailto not supported`) - } const claimed = await collect( delegations.find({ audience: claimedAudience }) ) diff --git a/packages/access-api/src/types/delegations.ts b/packages/access-api/src/types/delegations.ts index eaf6bbc19..b72b439c4 100644 --- a/packages/access-api/src/types/delegations.ts +++ b/packages/access-api/src/types/delegations.ts @@ -1,7 +1,7 @@ import * as Ucanto from '@ucanto/interface' interface ByAudience { - audience: Ucanto.DID<'key'> + audience: Ucanto.DID<'key' | 'mailto'> } export type Query = ByAudience diff --git a/packages/access-api/test/access-authorize.test.js b/packages/access-api/test/access-authorize.test.js index a4ec35d6d..c14e411aa 100644 --- a/packages/access-api/test/access-authorize.test.js +++ b/packages/access-api/test/access-authorize.test.js @@ -1,4 +1,7 @@ -import { stringToDelegation } from '@web3-storage/access/encoding' +import { + stringToDelegation, + bytesToDelegations, +} from '@web3-storage/access/encoding' import * as Access from '@web3-storage/capabilities/access' import assert from 'assert' import pWaitFor from 'p-wait-for' @@ -7,6 +10,7 @@ import { context } from './helpers/context.js' // @ts-ignore import isSubset from 'is-subset' import { toEmail } from '../src/utils/did-mailto.js' +import { warnOnErrorResult } from './helpers/ucanto-test-utils.js' /** @type {typeof assert} */ const t = assert @@ -95,6 +99,99 @@ describe('access/authorize', function () { assert(html.includes(toEmail(accountDID))) }) + it('should send confirmation email with link that, when clicked, allows for access/claim', async function () { + const { issuer, service, conn, mf } = ctx + const accountDID = 'did:mailto:dag.house:email' + + const inv = await Access.authorize + .invoke({ + issuer, + audience: service, + with: issuer.did(), + nb: { + as: accountDID, + }, + }) + .execute(conn) + + // @todo - this only returns string when ENV==='test'. Remove that env-specific behavior + assert.ok(typeof inv === 'string', 'invocation result is a string') + + const confirmEmailPostUrl = new URL(inv) + const confirmEmailPostResponse = await mf.dispatchFetch( + confirmEmailPostUrl, + { method: 'POST' } + ) + assert.deepEqual( + confirmEmailPostResponse.status, + 200, + 'confirmEmailPostResponse status is 200' + ) + + const claim = Access.claim.invoke({ + issuer, + audience: conn.id, + with: issuer.did(), + }) + const claimResult = await claim.execute(conn) + assert.ok( + 'delegations' in claimResult, + 'claimResult should have delegations property' + ) + const claimedDelegations = Object.values(claimResult.delegations).flatMap( + (bytes) => { + return bytesToDelegations( + /** @type {import('@web3-storage/access/src/types.js').BytesDelegation} */ ( + bytes + ) + ) + } + ) + assert.deepEqual( + claimedDelegations.length, + 1, + 'should have claimed delegation(s)' + ) + + const claimedDelegationIssuedByService = claimedDelegations.find((d) => { + if (!('cid' in d.proofs[0])) { + throw new Error('proof must be delegation') + } + return d.proofs[0].issuer.did() === service.did() + }) + assert.ok( + claimedDelegationIssuedByService, + 'should claim ucan/attest with proof.iss=service' + ) + + // we can use claimedDelegationIssuedByService to invoke access/claim as iss=accountDID + const account = issuer.withDID(accountDID) + const claimAsAccount = Access.claim.invoke({ + issuer: account, + audience: service, + with: account.did(), + proofs: [ + // allows signing with issuer.signer as iss=accountDID + claimedDelegationIssuedByService.proofs[0], + ], + }) + const claimAsAccountResult = await claimAsAccount.execute(conn) + warnOnErrorResult(claimAsAccountResult) + assert.notDeepEqual( + claimAsAccountResult.error, + true, + 'claimAsAccountResult should not error' + ) + assert.ok( + 'delegations' in claimAsAccountResult, + 'claimAsAccountResult should have delegations property' + ) + const claimedAsAccountDelegations = Object.values( + claimAsAccountResult.delegations + ) + assert.deepEqual(claimedAsAccountDelegations.length, 0) + }) + it('should receive delegation in the ws', async function () { const { issuer, service, conn, mf } = ctx const accountDID = 'did:mailto:dag.house:email' diff --git a/packages/access-api/test/access-delegate.test.js b/packages/access-api/test/access-delegate.test.js index 7a60bb77f..b2d9560e7 100644 --- a/packages/access-api/test/access-delegate.test.js +++ b/packages/access-api/test/access-delegate.test.js @@ -77,9 +77,46 @@ for (const handlerVariant of /** @type {const} */ ([ it(`InsufficientStorage if DID in the with field has no storage provider`, async () => { await testInsufficientStorageIfNoStorageProvider(handlerVariant) }) + + it(`can access/delegate against registered space`, async () => { + const service = await handlerVariant.audience + const spaceWithStorageProvider = + await handlerVariant.spaceWithStorageProvider + const delegateResult = await testCanAccessDelegateWithRegisteredSpace({ + space: spaceWithStorageProvider, + service, + invoke: handlerVariant.invoke, + }) + assert.notDeepEqual( + delegateResult.error, + true, + 'delegate result is not an error' + ) + }) }) } +/** + * @param {object} options + * @param {Ucanto.Signer>} options.space - registered space + * @param {Ucanto.Principal} options.service + * @param {(invocation: Ucanto.Invocation) => Promise} options.invoke + */ +async function testCanAccessDelegateWithRegisteredSpace(options) { + const delegate = await Access.delegate + .invoke({ + issuer: options.space, + audience: options.service, + with: options.space.did(), + nb: { + delegations: {}, + }, + }) + .delegate() + const delegateResult = await options.invoke(delegate) + return delegateResult +} + /** * Run the same tests against several variants of ( access/delegate | access/claim ) handlers. */ diff --git a/packages/access-api/test/voucher-claim.test.js b/packages/access-api/test/voucher-claim.test.js index ea7321d64..0d791ed8a 100644 --- a/packages/access-api/test/voucher-claim.test.js +++ b/packages/access-api/test/voucher-claim.test.js @@ -1,8 +1,9 @@ -import { Delegation } from '@ucanto/core' +import { Delegation, invoke } from '@ucanto/core' import * as Voucher from '@web3-storage/capabilities/voucher' import { stringToDelegation } from '@web3-storage/access/encoding' import { context } from './helpers/context.js' import assert from 'assert' +import * as principal from '@ucanto/principal' /** @type {typeof assert} */ const t = assert @@ -67,3 +68,43 @@ describe('ucan', function () { } }) }) + +describe('voucher/claim', () => { + it('invoking delegation from confirmation email should not error', async () => { + const { service, conn } = await context() + const issuer = await principal.ed25519.generate() + const claim = Voucher.claim.invoke({ + issuer, + audience: service, + with: issuer.did(), + nb: { + identity: 'mailto:email@dag.house', + product: 'product:free', + service: service.did(), + }, + }) + // @todo should not need to cast to string + // this function only returns a string when ENV==='test' and that's weird + const claimResult = /** @type {string} */ (await claim.execute(conn)) + assert.deepEqual(typeof claimResult, 'string', 'claim result is a string') + const confirmEmailDelegation = await stringToDelegation( + claimResult + ).delegate() + const confirmEmailReceipt = await invoke({ + issuer, + audience: service, + capability: confirmEmailDelegation.capabilities[0], + proofs: [confirmEmailDelegation], + }).delegate() + const [confirmEmailReceiptResult] = await conn.execute( + /** @type {any} */ (confirmEmailReceipt) + ) + assert.notDeepEqual( + confirmEmailReceiptResult && + 'error' in confirmEmailReceiptResult && + confirmEmailReceiptResult.error, + true, + 'invocation result is not an error' + ) + }) +})