Skip to content

Commit

Permalink
feat!: implement new account-based multi-device flow (#433)
Browse files Browse the repository at this point in the history
With this PR we're able to use two different devices on behalf of a
single account identified by an email address.

An agent (ie, a device like w3console or w3cli) can now:

1) use `access/authorize` to trigger an email verification flow that
will give them delegations to act on behalf of an account
2) create a space locally
3) add a storage provider to that space with `provider/add`
4) delegate capabilities to the account they are authorized as that
permit the account to delegate all capabilities on those spaces to other
agents - in other words, create spaces and assign all "permissions" on
those spaces to their account
5) upload data to the space

A second agent (ie, another device) can then:
1) use `access/authorize` to trigger an email verification flow that
will give them delegations to act on behalf of the same account
2) get a list of spaces they can store data in, which includes the space
created on the first device
3) upload data to the space

This PR also contains various refactoring of the `Agent` class to
minimize its responsibilities and move in the direction of letting user
agents take responsibility for state storage.

refs #395

* [x] setup tests for access-client agent + access-api
* [x] simple test agent createSpace
* [x] @gobengo test agent authorize happy path
#535
* [x] @gobengo upgrade to ucanto 6.2
#541
* [x] @travis ensure what's proposed here can work in w3up-client, w3ui,
w3console
* [x] upgrade this branch to `@ucanto/transport@5.1.1` after
storacha/ucanto#261
* [x] minimize new public api surface area on access-client Agent
* [x] (e.g. `sessionProof`)
https://github.com/web3-storage/w3protocol/pull/545/files
* [x] `sessionPrincipal`
#546
* [x] review comments
* [x] `authorize` should access/claim `with=did:mailto:...`
https://github.com/web3-storage/w3protocol/pull/556/files#

---------

Co-authored-by: Travis Vachon <travis.vachon@gmail.com>
Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com>
Co-authored-by: Irakli Gozalishvili <contact@gozala.io>
  • Loading branch information
4 people committed Mar 17, 2023
1 parent 1bad951 commit 1ddc6a0
Show file tree
Hide file tree
Showing 31 changed files with 2,902 additions and 1,207 deletions.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
"docs:markdown": "pnpm run build && docusaurus generate-typedoc"
},
"devDependencies": {
"@docusaurus/core": "^2.2.0",
"@docusaurus/core": "^2.3.1",
"docusaurus-plugin-typedoc": "^0.18.0",
"lint-staged": "^13.1.0",
"lint-staged": "^13.2.0",
"prettier": "2.8.3",
"simple-git-hooks": "^2.8.1",
"typedoc-plugin-markdown": "^3.14.0",
"typescript": "4.9.5",
"wrangler": "^2.8.0"
"wrangler": "^2.12.3"
},
"simple-git-hooks": {
"pre-commit": "npx lint-staged"
Expand All @@ -44,7 +44,7 @@
},
"dependencies": {
"depcheck": "^1.4.3",
"typedoc": "^0.23.22",
"typedoc": "^0.23.26",
"typedoc-plugin-missing-exports": "^1.0.0"
},
"packageManager": "pnpm@7.24.3",
Expand Down
13 changes: 7 additions & 6 deletions packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@
"license": "(Apache-2.0 OR MIT)",
"dependencies": {
"@ipld/dag-ucan": "^3.2.0",
"@ucanto/core": "^5.1.0",
"@ucanto/interface": "^6.0.0",
"@ucanto/core": "^5.2.0",
"@ucanto/interface": "^6.2.0",
"@ucanto/principal": "^5.1.0",
"@ucanto/server": "^6.0.0",
"@ucanto/transport": "^5.1.0",
"@ucanto/validator": "^6.0.0",
"@ucanto/server": "^6.1.0",
"@ucanto/transport": "^5.1.1",
"@ucanto/validator": "^6.1.0",
"@web3-storage/access": "workspace:^",
"@web3-storage/capabilities": "workspace:^",
"@web3-storage/worker-utils": "0.4.3-dev",
"kysely": "^0.23.4",
"kysely-d1": "^0.1.0",
"multiformats": "^11.0.1",
"multiformats": "^11.0.2",
"p-retry": "^5.1.2",
"preact": "^10.11.3",
"preact-render-to-string": "^5.2.6",
Expand Down Expand Up @@ -94,6 +94,7 @@
"error",
{
"definedTypes": [
"AsyncIterable",
"AsyncIterableIterator",
"Awaited",
"D1Database",
Expand Down
17 changes: 2 additions & 15 deletions packages/access-api/src/routes/validate-email.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
ValidateEmailError,
PendingValidateEmail,
} from '../utils/html.js'
import * as validator from '@ucanto/validator'
import { Verifier } from '@ucanto/principal'
import * as delegationsResponse from '../utils/delegations-response.js'
import * as accessConfirm from '../service/access-confirm.js'
Expand Down Expand Up @@ -144,18 +143,6 @@ async function authorize(req, env) {
*/
const request = stringToDelegation(req.query.ucan)

const confirmation = await validator.access(request, {
capability: Access.confirm,
principal: Verifier,
authority: env.signer,
})

if (confirmation.error) {
throw new Error(`unable to validate access session: ${confirmation}`, {
cause: confirmation.error,
})
}

const confirm = provide(
Access.confirm,
async ({ capability, invocation }) => {
Expand All @@ -168,12 +155,12 @@ async function authorize(req, env) {
}
)
const confirmResult = await confirm(request, {
id: env.signer.verifier,
id: env.signer,
principal: Verifier,
})
if (confirmResult.error) {
throw new Error('error confirming', {
cause: confirmResult.error,
cause: confirmResult,
})
}
const { account, agent } = accessConfirm.parse(request)
Expand Down
6 changes: 4 additions & 2 deletions packages/access-api/src/service/access-authorize.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as Server from '@ucanto/server'
import * as Access from '@web3-storage/capabilities/access'
import * as Mailto from '../utils/did-mailto.js'
import * as DID from '@ipld/dag-ucan/did'
import { delegationToString } from '@web3-storage/access/encoding'

/**
Expand All @@ -25,7 +24,10 @@ export function accessAuthorizeProvider(ctx) {
const confirmation = await Access.confirm
.invoke({
issuer: ctx.signer,
audience: DID.parse(capability.nb.iss),
// audience same as issuer because this is a service invocation
// that will get handled by access/confirm handler
// but only if the receiver of this email wants it to be
audience: ctx.signer,
// Because with is set to our DID no other actor will be able to issue
// this delegation without our private key.
with: ctx.signer.did(),
Expand Down
66 changes: 48 additions & 18 deletions packages/access-api/src/service/access-confirm.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Ucanto from '@ucanto/interface'
import * as ucanto from '@ucanto/core'
import { Verifier, Absentee } from '@ucanto/principal'
import { Absentee, Verifier } from '@ucanto/principal'
import { collect } from 'streaming-iterables'
import * as Access from '@web3-storage/capabilities/access'
import { delegationsToString } from '@web3-storage/access/encoding'
Expand Down Expand Up @@ -50,29 +50,19 @@ export async function handleAccessConfirm(invocation, ctx) {
}))
)

// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: account,
audience: agent,
const [delegation, attestation] = await createSessionProofs({
service: ctx.signer,
account,
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 },
delegationProofs: ctx.models.delegations.find({
audience: account.did(),
}),
expiration: Infinity,
})

Expand All @@ -89,3 +79,43 @@ export async function handleAccessConfirm(invocation, ctx) {
delegations: delegationsResponse.encode([delegation, attestation]),
}
}

/**
* @param {object} opts
* @param {Ucanto.Signer} opts.service
* @param {Ucanto.Principal<Ucanto.DID<'mailto'>>} opts.account
* @param {Ucanto.Principal<Ucanto.DID>} opts.agent
* @param {Ucanto.Capabilities} opts.capabilities
* @param {AsyncIterable<Ucanto.Delegation>} opts.delegationProofs
* @param {number} opts.expiration
* @returns {Promise<[delegation: Ucanto.Delegation, attestation: Ucanto.Delegation]>}
*/
export async function createSessionProofs({
service,
account,
agent,
capabilities,
delegationProofs,
// default to Infinity is reasonable here because
// account consented to this.
expiration = Infinity,
}) {
// create an delegation on behalf of the account with an absent signature.
const delegation = await ucanto.delegate({
issuer: Absentee.from({ id: account.did() }),
audience: agent,
capabilities,
expiration,
proofs: [...(await collect(delegationProofs))],
})

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

return [delegation, attestation]
}
8 changes: 8 additions & 0 deletions packages/access-api/src/types/ucanto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Ucanto from '@ucanto/interface'

export type ServiceInvoke<
Service extends Record<string, any>,
InvocationCapabilities extends Ucanto.Capability = Ucanto.Capability
> = <Capability extends InvocationCapabilities>(
invocation: Ucanto.ServiceInvocation<Capability>
) => Promise<Ucanto.InferServiceInvocationReturn<Capability, Service>>
5 changes: 3 additions & 2 deletions packages/access-api/test/access-authorize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('access/authorize', function () {
)
const delegation = stringToDelegation(encoded)
t.deepEqual(delegation.issuer.did(), service.did())
t.deepEqual(delegation.audience.did(), accountDID)
t.deepEqual(delegation.audience.did(), service.did())
t.deepEqual(delegation.capabilities, [
{
with: conn.id.did(),
Expand Down Expand Up @@ -122,8 +122,9 @@ describe('access/authorize', function () {

const url = new URL(email.url)
const rsp = await mf.dispatchFetch(url, { method: 'POST' })
const html = await rsp.text()
assert.deepEqual(rsp.status, 200)

const html = await rsp.text()
assert(html.includes('Email Validated'))
assert(html.includes(toEmail(accountDID)))
assert(html.includes(issuer.did()))
Expand Down
Loading

0 comments on commit 1ddc6a0

Please sign in to comment.