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

upgrade ucanto in authorize feat branch #540

Closed
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"error",
{
"definedTypes": [
"AsyncIterable",
"AsyncIterableIterator",
"Awaited",
"D1Database",
Expand Down
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
121 changes: 121 additions & 0 deletions packages/access-api/src/service/access-confirm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as Ucanto from '@ucanto/interface'
import * as ucanto from '@ucanto/core'
import { 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'
import * as validator from '@ucanto/validator'

/**
* @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 = {
did: () => validator.DID.match({ method: 'key' }).from(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:*'),
}))
)

const [delegation, attestation] = await createSessionProofs(
ctx.signer,
account,
agent,
capabilities,
// 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.
ctx.models.delegations.find({
audience: account.did(),
}),
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]),
}
}

/**
* @param {Ucanto.Signer} service
* @param {Ucanto.Principal<Ucanto.DID<'mailto'>>} account
* @param {Ucanto.Principal<Ucanto.DID<'key'>>} agent
* @param {Ucanto.Capabilities} capabilities
* @param {AsyncIterable<Ucanto.Delegation>} delegationProofs
* @param {number} expiration
* @returns {Promise<[delegation: Ucanto.Delegation, attestation: Ucanto.Delegation]>}
*/
export async function createSessionProofs(
service,
account,
agent,
capabilities,
delegationProofs,
expiration
) {
// 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]
}
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
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>>
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
Loading