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 11 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
92 changes: 92 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,10 @@ 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'
import { ed25519 } from '@ucanto/principal'

/**
* @param {import('@web3-storage/worker-utils/router').ParsedRequest} req
Expand Down Expand Up @@ -134,6 +139,93 @@ 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 accountDID = accessSessionResult.audience.did()
// @todo: use new ucanto `Account` instead
const accountOneOffKey = await ed25519.generate()
const account = accountOneOffKey.withDID(accountDID)

const agentPubkey = accessSessionResult.capability.nb.key

const update = await ucanto.delegate({
Copy link
Contributor

Choose a reason for hiding this comment

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

It would really help to add comment above explaining what this delegation intends to do. In the version that I think this implements this delegation is attestation from us that account has authorized agent key to be able to sign ucans where issuer is account did.

issuer: env.signer,
audience: account,
capabilities: [
{
with: env.signer.did(),
can: './update',
nb: {
key: agentPubkey,
},
},
],
})
const attestKeyAuthorizesAccount = await ucanto.delegate({
Copy link
Contributor

Choose a reason for hiding this comment

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

Comment here describing what it's meant to do would also help, because it does not do what spec or issue suggests and it's not clear to me what the intentions are.

issuer: env.signer,
audience: { did: () => agentPubkey },
capabilities: [
{
with: env.signer.did(),
can: 'ucan/attest',
nb: {
proof: update.cid,
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be the CID for the (unsigned) delegation from an account to the agent. This delegation should be replacement for the update above. More concretely if update was attesting to the authorization of the key, this attestation should be for the authorization of the specific delegation.

That is the core change in the spec, if previously we were attesting to key authorization which implied sudo privileges, in a new version we attest to the specific delegation authorization. Which means it could be sudo privileges or a very specific capabilities.

Copy link
Contributor

Choose a reason for hiding this comment

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

Reading further down I think you could just remove this delegation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

},
},
],
proofs: [update],
})

// create delegations that should be claimable
const delegationAccountToKey = await ucanto.delegate({
Copy link
Contributor

Choose a reason for hiding this comment

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

This is somewhat correct, except it will have a signature from accountOneOffKey key as opposed to having an "absent" signature. Which may be ok temporarily, but not in MVP because those could fail to validate.

issuer: account,
audience: {
did() {
return agentPubkey
},
},
capabilities: [
{
with: 'ucan:*',
can: '*',
},
],
})
// generate a delegation to the key that we can save in
// models.delegations to be found by subsequent access/claim
// invocations invoked by the did:key
const delegateToKey = await ucanto.delegate({
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably should be renamed and update comments as it does a different thing now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

renamed/recommented in 685af65

issuer: env.signer,
audience: {
Copy link
Contributor

Choose a reason for hiding this comment

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

Audience of ./update capabilities is meant to be non did:key principal, because those are only times ucanto would look at ./update capabilities. Delegation to the agent key is not going to get you anything other than been able to claim that delegation from that agent afterwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ok. I updated to not use ./update. If what I updated to is even remotely tolerable on staging, it would be real nice to merge it because any delegation in there with the correct audience will make the tests pass.

did() {
return agentPubkey
},
},
proofs: [delegationAccountToKey],
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure what is this for ? This could either contain delegation from did:web:web3.storage to a service key, or if they are the same no proof is required.

capabilities: [
{
can: 'ucan/attest',
with: env.signer.did(),
nb: {
proof: delegationAccountToKey.cid,
},
},
],
})
await env.models.delegations.putMany(
delegateToKey,
attestKeyAuthorizesAccount
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you need to put delegationAccountToKey and delegateToKey instead.

)
}

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
129 changes: 128 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,129 @@ 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,
2,
'should have claimed delegation(s)'
)
/**
* narrow Ucanto.Proof to Ucanto.Delegation
*
* @param {import('@ucanto/interface').Proof} proof
*/
// eslint-disable-next-line unicorn/consistent-function-scoping
const proofDelegation = (proof) => {
if (!('cid' in proof)) {
throw new Error('proof must be delegation')
}
return proof
}
const attest = claimedDelegations.find(
(d) => proofDelegation(d.proofs[0])?.issuer.did() === accountDID
)
assert.ok(
attest,
'should claim ucan/attest delegation with proof.iss=accountDID'
)
assert.deepEqual(attest.issuer.did(), service.did())
assert.deepEqual(attest.audience.did(), issuer.did())
assert.deepEqual(attest.capabilities[0].can, 'ucan/attest')
assert.deepEqual(attest.capabilities[0].with, service.did())

// ucan/attest nb.proof can be decoded into a delegation
const claimedNb = attest.capabilities[0].nb
assert.ok(
claimedNb && typeof claimedNb === 'object' && 'proof' in claimedNb,
'should have nb.proof'
)
const expectAccountToKey = proofDelegation(attest.proofs[0])
assert.ok(expectAccountToKey, 'expect proofs to contain delegation')
assert.deepEqual(expectAccountToKey.issuer.did(), accountDID)
assert.deepEqual(expectAccountToKey.audience.did(), issuer.did())
assert.deepEqual(expectAccountToKey.capabilities, [
Copy link
Contributor Author

Choose a reason for hiding this comment

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

remove this whole delegation if it's not needed for this test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done in 685af65

{ can: '*', with: 'ucan:*' },
])

const attestIssService = claimedDelegations.find(
(d) => proofDelegation(d.proofs[0])?.issuer.did() === service.did()
)
assert.ok(
attestIssService,
'should claim ucan/attest with proof.iss=service'
)
const accountAuthorization = attestIssService.proofs[0]
const account = issuer.withDID(accountDID)
const claimAsAccount = Access.claim.invoke({
issuer: account,
audience: service,
with: account.did(),
proofs: [accountAuthorization],
})
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'
)
})
})