Skip to content

Commit

Permalink
feat: access/authorize confirmation email click results in a delegati…
Browse files Browse the repository at this point in the history
…on back to the issuer did:key so that access/claim works (#460)

Motivation:
* #455 
* This implements the second delegation described in
#457
* make this test pass once we deploy this to staging
gobengo/w3protocol-test#6

Test Case:
*
https://github.com/gobengo/w3protocol-test/pull/6/files#diff-698e92d7467a4a470689d4cb120272c6cac28864d4960ac12a3720ab7c35a15cR364

---------

Co-authored-by: Irakli Gozalishvili <contact@gozala.io>
  • Loading branch information
gobengo and Gozala committed Feb 28, 2023
1 parent baacf35 commit a466a7d
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 8 deletions.
2 changes: 1 addition & 1 deletion packages/access-api/src/models/delegations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/access-api/src/routes/validate-email.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions packages/access-api/src/service/access-claim.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 })
)
Expand Down
2 changes: 1 addition & 1 deletion packages/access-api/src/types/delegations.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
99 changes: 98 additions & 1 deletion packages/access-api/test/access-authorize.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down
37 changes: 37 additions & 0 deletions packages/access-api/test/access-delegate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ucanto.DID<'key'>>} options.space - registered space
* @param {Ucanto.Principal} options.service
* @param {(invocation: Ucanto.Invocation<AccessDelegate>) => Promise<import('../src/service/access-delegate.js').AccessDelegateResult>} 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.
*/
Expand Down
43 changes: 42 additions & 1 deletion packages/access-api/test/voucher-claim.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
)
})
})

0 comments on commit a466a7d

Please sign in to comment.