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!: authorization and registration flows #1059

Merged
merged 26 commits into from
Nov 6, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9477a4a
feat!: update space creation logic
Gozala Oct 30, 2023
a9cd7c7
fix: failing tests
Gozala Oct 30, 2023
3e9f772
fix: address remaining issues
Gozala Oct 31, 2023
d66dfda
feat!: capture authorization requests
Gozala Nov 1, 2023
f762f06
stash!: save current changes
Gozala Nov 2, 2023
349b8b5
fix: post rebase issues
Gozala Nov 2, 2023
0194ade
revert changes caused by rebase
Gozala Nov 2, 2023
61e50f3
fix: remaining issues
Gozala Nov 3, 2023
54a105b
Merge remote-tracking branch 'origin/main' into feat/auth
Gozala Nov 3, 2023
7bddc8e
Apply suggestions from code review
Gozala Nov 3, 2023
5147cf9
chore: undo change
Gozala Nov 3, 2023
0b30d42
fix: lockfile
Gozala Nov 3, 2023
e78681c
chore: add ts project ref
Gozala Nov 3, 2023
9080374
fix: eslint issues
Gozala Nov 3, 2023
3cbce67
chore: remove nickname file
Gozala Nov 3, 2023
8517584
Apply suggestions from code review
Gozala Nov 4, 2023
92f12bc
address more review feedback
Gozala Nov 4, 2023
96b22ca
Merge remote-tracking branch 'origin/main' into feat/auth
Gozala Nov 4, 2023
9fa45cd
fix: remove proof validation
Gozala Nov 4, 2023
223929f
fix: adopt same timestamp format as UCANs use.
Gozala Nov 4, 2023
f1130fe
chore: remove high level provision from agent
Gozala Nov 4, 2023
acfc593
chore: add missing doc comments
Gozala Nov 4, 2023
4c30398
fix: lint error
Gozala Nov 4, 2023
8b5c1a0
Apply suggestions from code review
Gozala Nov 6, 2023
6834454
Update packages/access-client/src/access.js
Gozala Nov 6, 2023
11740c3
fix: lint error
Gozala Nov 6, 2023
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
10 changes: 8 additions & 2 deletions packages/access-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"exports": {
".": "./dist/src/index.js",
"./agent": "./dist/src/agent.js",
"./space": "./dist/src/space.js",
"./drivers/*": "./dist/src/drivers/*.js",
"./stores/*": "./dist/src/stores/*.js",
"./types": "./dist/src/types.js",
Expand All @@ -37,6 +38,9 @@
"agent": [
"dist/src/agent"
],
"space": [
"dist/src/space"
],
"types": [
"dist/src/types"
],
Expand Down Expand Up @@ -74,7 +78,8 @@
"one-webcrypto": "git://github.com/web3-storage/one-webcrypto",
"p-defer": "^4.0.0",
"type-fest": "^3.3.0",
"uint8arrays": "^4.0.6"
"uint8arrays": "^4.0.6",
"@scure/bip39": "^1.2.1"
},
"devDependencies": {
"@web3-storage/eslint-config-w3up": "workspace:^",
Expand Down Expand Up @@ -106,7 +111,8 @@
"mocha": true
},
"ignorePatterns": [
"dist"
"dist",
"src/types.js"
]
},
"depcheck": {
Expand Down
298 changes: 298 additions & 0 deletions packages/access-client/src/access.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import * as Access from '@web3-storage/capabilities/access'
import * as API from './types.js'
import { Failure, fail, DID } from '@ucanto/core'
import { Agent, importAuthorization } from './agent.js'
import { bytesToDelegations } from './encoding.js'

/**
* Takes array of delegations and propagates them to their respective audiences
* through a given space (or the current space if none is provided).
*
* Returns error result if agent has no current space and no space was provided.
* Also returns error result if invocation fails.
*
* @param {Agent} agent - Agent connected to the w3up service.
* @param {object} input
* @param {API.Delegation[]} input.delegations - Delegations to propagate.
* @param {API.SpaceDID} [input.space] - Space to propagate through.
* @param {API.Delegation[]} [input.proofs]
*/
export const delegate = async (
agent,
{ delegations, proofs = [], space = agent.currentSpace() }
) => {
if (!space) {
return fail('Space must be specified')
}

const entries = Object.values(delegations).map((proof) => [
proof.cid.toString(),
proof.cid,
])

const { out } = await agent.invokeAndExecute(Access.delegate, {
with: space,
nb: {
delegations: Object.fromEntries(entries),
},
// must be embedded here because it's referenced by cid in .nb.delegations
proofs: [...delegations, ...proofs],
})

return out
}

/**
* Requests specified `access` level from specified `account`. It will invoke
* `access/authorize` capability and keep polling `access/claim` capability
* until access is granted or request is aborted.
*
* @param {API.Agent} agent
* @param {object} input
* @param {API.AccountDID} input.account
* @param {API.ProviderDID} [input.provider]
* @param {API.DID} [input.audience]
* @param {API.Access} [input.access]
* @returns {Promise<API.Result<PendingAccessRequest, API.AccessAuthorizeFailure|API.InvocationError>>}
*/
export const request = async (
agent,
{
account,
provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()),
audience = agent.did(),
access = spaceAccess,
}
) => {
// Request access from the account.
const { out: result } = await agent.invokeAndExecute(Access.authorize, {
audience: DID.parse(provider),
with: audience,
nb: {
iss: account,
// New ucan spec moved to recap style layout for capabilities and new
// `access/request` will use similar format as opposed to legacy one,
// in the meantime we translate new format to legacy format here.
att: [...toCapabilities(access)],
},
})

return result.error
? result
: {
ok: new PendingAccessRequest({
...result.ok,
agent,
audience,
provider,
}),
}
}

/**
* Claims access that has been delegated to the given audience, which by
* default is the agent's DID.
*
* @param {API.Agent} agent
* @param {object} input
* @param {API.DID} [input.audience]
* @param {API.ProviderDID} [input.provider]
* @returns {Promise<API.Result<GrantedAccess, API.AccessClaimFailure|API.InvocationError>>}
*/
export const claim = async (
agent,
{
provider = /** @type {API.ProviderDID} */ (agent.connection.id.did()),
audience = agent.did(),
} = {}
) => {
const { out: result } = await agent.invokeAndExecute(Access.claim, {
audience: DID.parse(provider),
with: audience,
})

if (result.error) {
return result
} else {
const delegations = Object.values(result.ok.delegations)
const proofs = delegations.flatMap((proof) => bytesToDelegations(proof))
return { ok: new GrantedAccess({ agent, provider, audience, proofs }) }
}
}

/**
* Represents a pending access request. It can be used to poll for the requested
* delegation.
*/
class PendingAccessRequest {
/**
* @param {object} source
* @param {API.Agent} source.agent
* @param {API.DID} source.audience
* @param {API.ProviderDID} source.provider
* @param {number} source.expiration
Copy link
Contributor

Choose a reason for hiding this comment

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

what kind of number? e.g. is it number of second or milliseconds since epoch? something else?
(should it be a Date?)

* @param {API.Link} source.request
*/
constructor({ agent, audience, provider, expiration, request }) {
this.agent = agent
this.audience = audience
this.expiration = expiration
this.request = request
this.provider = provider
}

/**
*
Gozala marked this conversation as resolved.
Show resolved Hide resolved
* @returns {Promise<API.Result<API.Delegation[], API.InvocationError|API.AccessClaimFailure|RequestExpired>>}
*/
async poll() {
Copy link
Member

Choose a reason for hiding this comment

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

How should this class be used? Should I call poll or claim on instances I receive? The docs above say I can use this to poll for the requested delegation but I think I would need to call claim not poll for that behaviour?

It seems back to front to me that poll sends a single request, but claim sends multiple requests. Perhaps poll should be a private method?

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 thinking was unless you do want to poll manually you could call .claim() which will keep polling until it's succeeds or fails. But if you have a reason to poll yourself you could use this. I'll add doc comment to this effect.

const { agent, audience, provider, expiration, request } = this
const timeout = expiration - Date.now()
if (timeout <= 0) {
return { error: new RequestExpired({ expiration, request }) }
} else {
const result = await claim(agent, { audience, provider })
return result.error
? result
: {
ok: result.ok.proofs.filter((proof) =>
isRequestedAccess(proof, this)
),
}
}
}

/**
* @param {object} options
Gozala marked this conversation as resolved.
Show resolved Hide resolved
* @param {number} [options.interval]
* @param {AbortSignal} [options.signal]
* @returns {Promise<API.Result<GrantedAccess, Error>>}
*/
async claim({ signal, interval = 250 } = {}) {
while (signal?.aborted !== true) {
const result = await this.poll()
// If polling failed, return the error.
if (result.error) {
return result
}
// If we got some matching proofs, return them.
else if (result.ok.length > 0) {
return {
ok: new GrantedAccess({
agent: this.agent,
provider: this.provider,
audience: this.audience,
proofs: result.ok,
}),
}
}

await new Promise((resolve) => setTimeout(resolve, interval))
}

return {
error: Object.assign(new Error('Aborted'), { reason: signal.reason }),
}
}
}

class RequestExpired extends Failure {
/**
* @param {object} source
* @param {number} source.expiration
* @param {API.Link} source.request
*/
constructor({ request, expiration }) {
super()
this.request = request
this.expiration = expiration
}

get name() {
return 'RequestExpired'
}

describe() {
return `Access request expired at ${new Date(this.expiration)} for ${
this.request
} request.`
}
}

class GrantedAccess {
/**
* @param {object} source
* @param {API.Agent} source.agent
* @param {API.Delegation[]} source.proofs
* @param {API.ProviderDID} source.provider
* @param {API.DID} source.audience
*/
constructor(source) {
this.source = source
}
get proofs() {
return this.source.proofs
}
get provider() {
return this.source.provider
}
get authority() {
return this.source.audience
}

/**
* @param {object} input
* @param {API.Agent} [input.agent]
*/
save({ agent = this.source.agent } = {}) {
return importAuthorization(agent, this)
}
}

/**
* Checks if the given delegation is caused by the passed `request` for access.
*
* @param {API.Delegation} delegation
* @param {object} selector
* @param {API.Link} selector.request
* @returns
*/

Gozala marked this conversation as resolved.
Show resolved Hide resolved
const isRequestedAccess = (delegation, { request }) =>
delegation.facts.some((fact) => `${fact['access/request']}` === `${request}`)

/**
* @param {API.Access} access
* @returns {{ can: API.Ability }[]}
*/
export const toCapabilities = (access) => {
const abilities = []
const entries = /** @type {[API.Ability, API.Unit][]} */ (
Object.entries(access)
)

for (const [can, details] of entries) {
if (details) {
abilities.push({ can })
}
}
return abilities
}

/**
* Set of capabilities required for by the agent to manage a space.
Gozala marked this conversation as resolved.
Show resolved Hide resolved
*/
export const spaceAccess = {
'space/*': {},
'store/*': {},
'upload/*': {},
'access/*': {},
'filecoin/*': {},
}

/**
* Set of capabilities required for by the agent to manage an account.
*/
export const accountAccess = {
'*': {},
}
6 changes: 3 additions & 3 deletions packages/access-client/src/agent-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Signer as EdSigner } from '@ucanto/principal/ed25519'
import { importDAG } from '@ucanto/core/delegation'
import * as Ucanto from '@ucanto/interface'
import { CID } from 'multiformats'
import { Access } from '@web3-storage/capabilities'
import { UCAN } from '@web3-storage/capabilities'
import { isExpired } from './delegations.js'

/** @typedef {import('./types.js').AgentDataModel} AgentDataModel */
Expand Down Expand Up @@ -156,13 +156,13 @@ export class AgentData {
* @param {Ucanto.Capability} cap
* @returns {boolean}
*/
const isSessionCapability = (cap) => cap.can === Access.session.can
const isSessionCapability = (cap) => cap.can === UCAN.attest.can

/**
* Is the given delegation a session proof?
*
* @param {Ucanto.Delegation} delegation
* @returns {delegation is Ucanto.Delegation<[import('./types.js').AccessSession]>}
* @returns {delegation is Ucanto.Delegation<[import('./types.js').UCANAttest]>}
*/
export const isSessionProof = (delegation) =>
delegation.capabilities.some((cap) => isSessionCapability(cap))
Expand Down
Loading
Loading