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 7 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
36 changes: 36 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,38 @@ 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}`
)
}
// 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 accessSessionResult.capability.nb.key
},
},
proofs: [delegation],
capabilities: [
{
can: './update',
with: env.signer.did(),
nb: accessSessionResult.capability.nb,
},
],
})
await env.models.delegations.putMany(delegateToKey)
}

try {
return new HtmlResponse(
Expand Down
47 changes: 47 additions & 0 deletions packages/access-api/test/access-authorize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,53 @@ 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 = claimResult.delegations
assert.deepEqual(
Object.values(claimedDelegations).length,
1,
'should have claimed 1 delegation'
)
})

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