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: access/authorize confirmation email click results in a delegation back to the issuer did:key so that access/claim works #460

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
can: 'access-api/delegation',
can: 'access/delegation'

Nit: It just occurred to me that you can use access/delegate here as spec-ed except with audience being an agent as opposed to service.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm down to change to that post-merge but otherwise I'd prefer to make this a meaningless can

},
],
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'
)
})
})