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: define access/confirm handler and use it in ucanto-test-utils registerSpaces + validate-email handler #530

Merged
merged 11 commits into from
Mar 14, 2023
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(
Copy link
Member

Choose a reason for hiding this comment

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

is this all going to be backwards compatible with the current implementation? backwards compatibility isn't necessarily a hard requirement, but I am interested in tracking what's likely to break for older clients

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My understanding is yes, it will be. All the old tests pass.
(this whole path is only exercised when clicking confirmation email sent by access/authorize, so it will be backward compatible with the not-very-many implementations of the client side of that, e.g. the observable I made)

Copy link
Member

Choose a reason for hiding this comment

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

OH right, the separate codepath for this should make it all work fine, cool!

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`)
}
Comment on lines +53 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

I would not limit this to tests only.

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 @@ -29,10 +29,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 @@ -190,10 +192,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