Skip to content

Commit

Permalink
feat: define access/confirm handler and use it in ucanto-test-utils…
Browse files Browse the repository at this point in the history
… registerSpaces + validate-email handler (#530)

Previously a lot of logic to handle `access/confirm` was in the
`validate-email` flow, since that is the most common place we'd receive
that invocation (after clicking email sent by `access/authorize`
handler).

However, this logic can be expressed as a `ServiceMethod` on invocation
of `access/confirm`, and validate-email can call that.
This allows us to also self-issue `access/confirm` in some tests, e.g.
in ucanto-test-utils `registerSpaces` and send it to our service to
handle, which is only enabled when node env is TEST for now.

Benefits:
* wherever we use ucanto-test-utils `registerSpaces`, we'll be
accurately testing the `access/confirm` + `provider/add` flow (not
old/deprecated `voucher/redeem`)

---------

Co-authored-by: Travis Vachon <travis@dag.house>
  • Loading branch information
gobengo and travis committed Mar 14, 2023
1 parent 86d77a1 commit 0471e89
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 116 deletions.
104 changes: 33 additions & 71 deletions packages/access-api/src/routes/validate-email.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
stringToDelegation,
delegationsToString,
stringToDelegation,
} from '@web3-storage/access/encoding'
import * as Access from '@web3-storage/capabilities/access'
import QRCode from 'qrcode'
Expand All @@ -11,10 +11,12 @@ import {
ValidateEmailError,
PendingValidateEmail,
} from '../utils/html.js'
import * as ucanto from '@ucanto/core'
import * as validator from '@ucanto/validator'
import { Verifier, Absentee } from '@ucanto/principal'
import { collect } from 'streaming-iterables'
import { Verifier } from '@ucanto/principal'
import * as delegationsResponse from '../utils/delegations-response.js'
import * as accessConfirm from '../service/access-confirm.js'
import { provide } from '@ucanto/server'
import * as Ucanto from '@ucanto/interface'

/**
* @param {import('@web3-storage/worker-utils/router').ParsedRequest} req
Expand Down Expand Up @@ -149,62 +151,35 @@ async function authorize(req, env) {
})

if (confirmation.error) {
throw new Error(`unable to validate access session: ${confirmation}`)
}
if (confirmation.capability.with !== env.signer.did()) {
throw new Error(`Not a valid access/confirm delegation`)
throw new Error(`unable to validate access session: ${confirmation}`, {
cause: confirmation.error,
})
}

// Create a absentee signer for the account that authorized the delegation
const account = Absentee.from({ id: confirmation.capability.nb.iss })
const agent = Verifier.parse(confirmation.capability.nb.aud)

// It the future we should instead render a page and allow a user to select
// which delegations they wish to re-delegate. Right now we just re-delegate
// everything that was requested for all of the resources.
const capabilities =
/** @type {ucanto.UCAN.Capabilities} */
(
confirmation.capability.nb.att.map(({ can }) => ({
can,
with: /** @type {ucanto.UCAN.Resource} */ ('ucan:*'),
}))
)

// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: account,
audience: agent,
capabilities,
expiration: Infinity,
// We include all the delegations to the account so that the agent will
// have delegation chains to all the delegated resources.
// We should actually filter out only delegations that support delegated
// capabilities, but for now we just include all of them since we only
// implement sudo access anyway.
proofs: await collect(
env.models.delegations.find({
audience: account.did(),
})
),
})

const attestation = await Access.session.delegate({
issuer: env.signer,
audience: agent,
with: env.signer.did(),
nb: { proof: delegation.cid },
expiration: Infinity,
const confirm = provide(
Access.confirm,
async ({ capability, invocation }) => {
return accessConfirm.handleAccessConfirm(
/** @type {Ucanto.Invocation<import('@web3-storage/access/types').AccessConfirm>} */ (
invocation
),
env
)
}
)
const confirmResult = await confirm(request, {
id: env.signer.verifier,
principal: Verifier,
})

// Store the delegations so that they can be pulled with access/claim
// The fact that we're storing proofs chains that we pulled from the
// database is not great, but it's a tradeoff we're making for now.
await env.models.delegations.putMany(delegation, attestation)

const authorization = delegationsToString([delegation, attestation])
// Send delegations to the client through a websocket
await env.models.validations.putSession(authorization, agent.did())
if (confirmResult.error) {
throw new Error('error confirming', {
cause: confirmResult.error,
})
}
const { account, agent } = accessConfirm.parse(request)
const confirmDelegations = [
...delegationsResponse.decode(confirmResult.delegations),
]

// We render HTML page explaining to the user what has happened and providing
// a QR code in the details if they want to drill down.
Expand All @@ -213,20 +188,7 @@ async function authorize(req, env) {
<ValidateEmail
email={toEmail(account.did())}
audience={agent.did()}
ucan={authorization}
qrcode={await QRCode.toString(authorization, {
type: 'svg',
errorCorrectionLevel: 'M',
margin: 10,
}).catch((error) => {
if (/too big to be stored in a qr/i.test(error.message)) {
env.log.error(error)
// It's not important to have the QR code
// eslint-disable-next-line unicorn/no-useless-undefined
return undefined
}
throw error
})}
ucan={delegationsToString(confirmDelegations)}
/>
)
)
Expand Down
91 changes: 91 additions & 0 deletions packages/access-api/src/service/access-confirm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import * as Ucanto from '@ucanto/interface'
import * as ucanto from '@ucanto/core'
import { Verifier, Absentee } from '@ucanto/principal'
import { collect } from 'streaming-iterables'
import * as Access from '@web3-storage/capabilities/access'
import { delegationsToString } from '@web3-storage/access/encoding'
import * as delegationsResponse from '../utils/delegations-response.js'

/**
* @typedef {import('@web3-storage/capabilities/types').AccessConfirmSuccess} AccessConfirmSuccess
* @typedef {import('@web3-storage/capabilities/types').AccessConfirmFailure} AccessConfirmFailure
*/

/**
* @param {Ucanto.Invocation<import('@web3-storage/capabilities/src/types').AccessConfirm>} invocation
*/
export function parse(invocation) {
const capability = invocation.capabilities[0]
// Create a absentee signer for the account that authorized the delegation
const account = Absentee.from({ id: capability.nb.iss })
const agent = Verifier.parse(capability.nb.aud)
return {
account,
agent,
}
}

/**
* @param {Ucanto.Invocation<import('@web3-storage/capabilities/src/types').AccessConfirm>} invocation
* @param {import('../bindings').RouteContext} ctx
* @returns {Promise<Ucanto.Result<AccessConfirmSuccess, AccessConfirmFailure>>}
*/
export async function handleAccessConfirm(invocation, ctx) {
const capability = invocation.capabilities[0]
if (capability.with !== ctx.signer.did()) {
throw new Error(`Not a valid access/confirm delegation`)
}

const { account, agent } = parse(invocation)

// It the future we should instead render a page and allow a user to select
// which delegations they wish to re-delegate. Right now we just re-delegate
// everything that was requested for all of the resources.
const capabilities =
/** @type {ucanto.UCAN.Capabilities} */
(
capability.nb.att.map(({ can }) => ({
can,
with: /** @type {ucanto.UCAN.Resource} */ ('ucan:*'),
}))
)

// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: account,
audience: agent,
capabilities,
expiration: Infinity,
// We include all the delegations to the account so that the agent will
// have delegation chains to all the delegated resources.
// We should actually filter out only delegations that support delegated
// capabilities, but for now we just include all of them since we only
// implement sudo access anyway.
proofs: await collect(
ctx.models.delegations.find({
audience: account.did(),
})
),
})

const attestation = await Access.session.delegate({
issuer: ctx.signer,
audience: agent,
with: ctx.signer.did(),
nb: { proof: delegation.cid },
expiration: Infinity,
})

// Store the delegations so that they can be pulled with access/claim
// The fact that we're storing proofs chains that we pulled from the
// database is not great, but it's a tradeoff we're making for now.
await ctx.models.delegations.putMany(delegation, attestation)

const authorization = delegationsToString([delegation, attestation])
// Save delegations for the validation process
await ctx.models.validations.putSession(authorization, agent.did())

return {
delegations: delegationsResponse.encode([delegation, attestation]),
}
}
17 changes: 17 additions & 0 deletions packages/access-api/src/service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Server from '@ucanto/server'
import * as validator from '@ucanto/validator'
import { Failure } from '@ucanto/server'
import * as Space from '@web3-storage/capabilities/space'
import * as Access from '@web3-storage/capabilities/access'
import { top } from '@web3-storage/capabilities/top'
import {
delegationToString,
Expand All @@ -17,6 +18,7 @@ import { accessDelegateProvider } from './access-delegate.js'
import { accessClaimProvider } from './access-claim.js'
import { providerAddProvider } from './provider-add.js'
import { Spaces } from '../models/spaces.js'
import { handleAccessConfirm } from './access-confirm.js'

/**
* @param {import('../bindings').RouteContext} ctx
Expand Down Expand Up @@ -45,6 +47,21 @@ export function service(ctx) {
config: ctx.config,
})(...args)
},
confirm: Server.provide(
Access.confirm,
async ({ capability, invocation }) => {
// only needed in tests
if (ctx.config.ENV !== 'test') {
throw new Error(`access/confirm is disabled`)
}
return handleAccessConfirm(
/** @type {Ucanto.Invocation<import('@web3-storage/access/types').AccessConfirm>} */ (
invocation
),
ctx
)
}
),
delegate: (...args) => {
// disable until hardened in test/staging
if (ctx.config.ENV === 'production') {
Expand Down
19 changes: 18 additions & 1 deletion packages/access-api/test/access-authorize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from './helpers/ucanto-test-utils.js'
import { ed25519, Absentee } from '@ucanto/principal'
import { delegate } from '@ucanto/core'
import { Space } from '@web3-storage/capabilities'

/** @type {typeof assert} */
const t = assert
Expand Down Expand Up @@ -312,8 +313,12 @@ describe('access/authorize', function () {
const space = await ed25519.generate()
const w3 = ctx.service

await registerSpaces([space], ctx)
const account = Absentee.from({ id: 'did:mailto:dag.house:test' })
await registerSpaces([space], {
...ctx,
agent: ctx.issuer,
account,
})

// delegate all space capabilities to the account
const delegation = await delegate({
Expand Down Expand Up @@ -342,6 +347,7 @@ describe('access/authorize', function () {
})
.execute(ctx.conn)

warnOnErrorResult(delegateResult)
assert.equal(delegateResult.error, undefined, 'delegation succeeded')

// Now generate an agent and try to authorize with the account
Expand Down Expand Up @@ -424,5 +430,16 @@ describe('access/authorize', function () {
delegation.cid,
'delegation to an account is included'
)

// use these delegations to do something on the space
const info = await Space.info
.invoke({
issuer: agent,
audience: w3,
with: space.did(),
proofs: [authorization, attestation],
})
.execute(ctx.conn)
assert.notDeepEqual(info.error, true, 'space/info did not error')
})
})
1 change: 1 addition & 0 deletions packages/access-api/test/access-claim.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ for (const handlerVariant of /** @type {const} */ ([
spaceWithStorageProvider,
...createTesterFromContext(() => context(), {
registerSpaces: [spaceWithStorageProvider],
account: { did: () => /** @type {const} */ ('did:mailto:foo') },
}),
}
})(),
Expand Down
4 changes: 4 additions & 0 deletions packages/access-api/test/access-delegate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ for (const handlerVariant of /** @type {const} */ ([
name: 'handled by access-api in miniflare',
...(() => {
const spaceWithStorageProvider = principal.ed25519.generate()
const account = { did: () => /** @type {const} */ ('did:mailto:foo') }
return {
spaceWithStorageProvider,
...createTesterFromContext(() => context(), {
registerSpaces: [spaceWithStorageProvider],
account,
}),
}
})(),
Expand Down Expand Up @@ -123,10 +125,12 @@ for (const variant of /** @type {const} */ ([
name: 'handled by access-api in miniflare',
...(() => {
const spaceWithStorageProvider = principal.ed25519.generate()
const account = { did: () => /** @type {const} */ ('did:mailto:foo') }
return {
spaceWithStorageProvider,
...createTesterFromContext(() => context(), {
registerSpaces: [spaceWithStorageProvider],
account,
}),
}
})(),
Expand Down
Loading

0 comments on commit 0471e89

Please sign in to comment.