Skip to content

Commit

Permalink
feat: get access/claim authorization wait function working (#666)
Browse files Browse the repository at this point in the history
Build on @Bengo's `access/claim`-polling-based
`waitForDelegationOnSocket` replacement. This will let us replace the
websocket-based authorization wait function which makes the UX much more
responsive - rather than waiting an indeterminate amount of time after
clicking the email link, the process completes almost immediately with
this change.

The wait function is also pluggable so we that clients can implement
different polling strategies, which should make it easier to test and
use a receipt-based version of this in the future.

Added tests for this that use `sinon` to mock out service handlers and
ensure they are called the right number of times.
  • Loading branch information
travis authored Mar 29, 2023
1 parent 068e801 commit 83971de
Show file tree
Hide file tree
Showing 7 changed files with 1,468 additions and 855 deletions.
32 changes: 8 additions & 24 deletions packages/access-api/test/access-client-agent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
authorizeAndWait,
claimAccess,
createDidMailtoFromEmail,
expectNewClaimableDelegations,
delegationsIncludeSessionProof,
pollAccessClaimUntil,
requestAccess,
waitForAuthorizationByPolling,
} from '@web3-storage/access/agent'
import * as w3caps from '@web3-storage/capabilities'
import * as assert from 'assert'
Expand Down Expand Up @@ -390,14 +392,10 @@ for (const accessApiVariant of /** @type {const} */ ([
const deviceA = await AccessAgent.create(undefined, {
connection,
})
const expectAuthorization = () =>
expectNewClaimableDelegations(deviceA, deviceA.issuer.did(), {
abort: abort.signal,
})
const authorize = () =>
authorizeAndWait(deviceA, account.email, {
signal: abort.signal,
expectAuthorization,
expectAuthorization: waitForAuthorizationByPolling,
})
const clickNextConfirmationLink = () =>
watchForEmail(emails, 100, abort.signal).then((email) => {
Expand Down Expand Up @@ -456,10 +454,11 @@ for (const accessApiVariant of /** @type {const} */ ([
const authorize = async () => {
// fire off request
await requestAccess(deviceA, account, [{ can: '*' }])
const claimed = await expectNewClaimableDelegations(
const claimed = await pollAccessClaimUntil(
delegationsIncludeSessionProof,
deviceA,
deviceA.issuer.did(),
{ abort: abort.signal }
{ signal: abort.signal }
)
return claimed
}
Expand Down Expand Up @@ -627,23 +626,8 @@ function thisEmailDidMailto() {
* @param {Iterable<{ can: Ucanto.Ability }>} [opts.capabilities]
*/
export async function authorizeWithPollClaim(access, email, opts) {
const expectAuthorization = () =>
expectNewClaimableDelegations(access, access.issuer.did(), {
abort: opts?.signal,
}).then((claimed) => {
if (
![...claimed].some((d) =>
d.capabilities.some((d) => d.can === w3caps.Access.session.can)
)
) {
throw new Error(
`claimed new delegations, but none were a session proof`
)
}
return [...claimed]
})
await authorizeAndWait(access, email, {
...opts,
expectAuthorization,
expectAuthorization: waitForAuthorizationByPolling,
})
}
2 changes: 2 additions & 0 deletions packages/access-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"@types/inquirer": "^9.0.3",
"@types/mocha": "^10.0.1",
"@types/node": "^18.11.18",
"@types/sinon": "^10.0.13",
"@types/varint": "^6.0.1",
"@types/ws": "^8.5.4",
"@ucanto/server": "^6.1.0",
Expand All @@ -95,6 +96,7 @@
"mocha": "^10.2.0",
"playwright-test": "^8.1.2",
"sade": "^1.8.1",
"sinon": "^15.0.3",
"typescript": "4.9.5",
"watch": "^1.0.2"
},
Expand Down
149 changes: 104 additions & 45 deletions packages/access-client/src/agent-use-cases.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { bytesToDelegations, stringToDelegation } from './encoding.js'
import { Provider } from '@web3-storage/capabilities'
import * as w3caps from '@web3-storage/capabilities'
import { Websocket, AbortError } from './utils/ws.js'
import { AgentData } from './agent-data.js'
import { AgentData, isSessionProof } from './agent-data.js'
import * as ucanto from '@ucanto/core'
import { DID as DIDValidator } from '@ucanto/validator'

Expand Down Expand Up @@ -40,8 +40,8 @@ export async function requestAccess(access, account, capabilities) {
*
* @param {AccessAgent} access
* @param {Ucanto.DID} [audienceOfClaimedDelegations] - audience of claimed delegations. defaults to access.connection.id.did()
* @param {object} options
* @param {boolean} [options.addProofs] - whether to addProof to access agent
* @param {object} opts
* @param {boolean} [opts.addProofs] - whether to addProof to access agent
* @returns
*/
export async function claimAccess(
Expand Down Expand Up @@ -92,20 +92,37 @@ export async function addProvider({ access, space, account, provider }) {
}

/**
* @typedef {(delegations: Ucanto.Delegation<Ucanto.Capabilities>[]) => boolean} DelegationsChecker
*/

/**
* @type DelegationsChecker
*/
export function delegationsIncludeSessionProof(delegations) {
return delegations.some((d) => isSessionProof(d))
}

/**
* @param {DelegationsChecker} delegationsMatch
* @param {AccessAgent} access
* @param {Ucanto.DID} delegee
* @param {object} [options]
* @param {number} [options.interval]
* @param {AbortSignal} [options.abort]
* @param {object} [opts]
* @param {number} [opts.interval]
* @param {AbortSignal} [opts.signal]
* @returns {Promise<Iterable<Ucanto.Delegation>>}
*/
export async function expectNewClaimableDelegations(access, delegee, options) {
const interval = options?.interval || 250
const claim = () => claimAccess(access, delegee)
const initialClaimResult = await claim()
export async function pollAccessClaimUntil(
delegationsMatch,
access,
delegee,
opts
) {
const interval = opts?.interval || 250
const claimed = await new Promise((resolve, reject) => {
options?.abort?.addEventListener('abort', (e) => {
reject(new Error('expectNewClaimableDelegations aborted', { cause: e }))
opts?.signal?.addEventListener('abort', (e) => {
reject(
new Error('pollAccessClaimUntilSessionProof aborted', { cause: e })
)
})
poll(interval)
/**
Expand All @@ -119,15 +136,17 @@ export async function expectNewClaimableDelegations(access, delegee, options) {
if (pollClaimResult.error) {
return reject(pollClaimResult)
}
// got a response. If it contains same amount of delegations as initialClaimResult,
// user has not clicked confirm
const claimedDelegations = Object.values(
pollClaimResult.delegations
).flatMap((d) => bytesToDelegations(d))
if (claimedDelegations.length > initialClaimResult.length) {
resolve(claimedDelegations)
} else {
setTimeout(() => poll(retryAfter), retryAfter)
try {
const claimedDelegations = Object.values(
pollClaimResult.delegations
).flatMap((d) => bytesToDelegations(d))
if (delegationsMatch(claimedDelegations)) {
resolve(claimedDelegations)
} else {
setTimeout(() => poll(retryAfter), retryAfter)
}
} catch (error) {
reject(error)
}
}
})
Expand All @@ -138,6 +157,7 @@ export async function expectNewClaimableDelegations(access, delegee, options) {
* @param {AccessAgent} access
* @param {object} [opts]
* @param {AbortSignal} [opts.signal]
* @deprecated - use waitForAuthorizationOnSocket
*/
export async function waitForDelegationOnSocket(access, opts) {
const ws = new Websocket(access.url, 'validate-ws')
Expand Down Expand Up @@ -170,6 +190,43 @@ export async function waitForDelegationOnSocket(access, opts) {
throw new TypeError('Failed to get delegation')
}

/**
* @typedef {{signal?: AbortSignal }} AuthorizationWaiterOpts
* @typedef {(accessAgent: AccessAgent, opts: AuthorizationWaiterOpts) => Promise<Iterable<Ucanto.Delegation>> } AuthorizationWaiter
*/

/**
* Wait for the authorization process to complete by waiting on a
* well-known websocket endpoint for the access-api server to
* receive and forward a session delegation from the authorization
* email flow.
*
* @type AuthorizationWaiter
*/
export async function waitForAuthorizationOnSocket(access, opts = {}) {
const delegation = await waitForDelegationOnSocket(access, opts)
return [delegation]
}

/**
* Wait for authorization process to complete by polling executions of the
* `access/claim` capability and waiting for the result to include
* a session delegation.
*
* @type AuthorizationWaiter
*/
export async function waitForAuthorizationByPolling(access, opts = {}) {
const claimed = await pollAccessClaimUntil(
delegationsIncludeSessionProof,
access,
access.issuer.did(),
{
signal: opts?.signal,
}
)
return [...claimed]
}

/**
* Request authorization of a session allowing this agent to issue UCANs
* signed by the passed email address.
Expand All @@ -180,16 +237,11 @@ export async function waitForDelegationOnSocket(access, opts) {
* @param {AbortSignal} [opts.signal]
* @param {boolean} [opts.dontAddProofs] - whether to skip adding proofs to the agent
* @param {Iterable<{ can: Ucanto.Ability }>} [opts.capabilities]
* @param {() => Promise<Iterable<Ucanto.Delegation>>} [opts.expectAuthorization] - function that will resolve once account has confirmed the authorization request
* @param {AuthorizationWaiter} [opts.expectAuthorization] - function that will resolve once account has confirmed the authorization request
*/
export async function authorizeAndWait(access, email, opts) {
export async function authorizeAndWait(access, email, opts = {}) {
const expectAuthorization =
opts?.expectAuthorization ||
function () {
return expectNewClaimableDelegations(access, access.issuer.did(), {
abort: opts?.signal,
})
}
opts.expectAuthorization || waitForAuthorizationByPolling
const account = { did: () => createDidMailtoFromEmail(email) }
await requestAccess(
access,
Expand All @@ -201,12 +253,31 @@ export async function authorizeAndWait(access, email, opts) {
{ can: 'upload/*' },
]
)
const sessionDelegations = [...(await expectAuthorization())]
const sessionDelegations = [...(await expectAuthorization(access, opts))]
if (!opts?.dontAddProofs) {
await Promise.all(sessionDelegations.map(async (d) => access.addProof(d)))
}
}

/**
* Request authorization of a session allowing this agent to issue UCANs
* signed by the passed email address.
*
* @param {AccessAgent} accessAgent
* @param {`${string}@${string}`} email
* @param {object} [opts]
* @param {AbortSignal} [opts.signal]
* @param {Iterable<{ can: Ucanto.Ability }>} [opts.capabilities]
* @param {boolean} [opts.addProofs]
* @param {AuthorizationWaiter} [opts.expectAuthorization] - function that will resolve once account has confirmed the authorization request
*/
export async function authorizeWaitAndClaim(accessAgent, email, opts) {
await authorizeAndWait(accessAgent, email, opts)
await claimAccess(accessAgent, accessAgent.issuer.did(), {
addProofs: opts?.addProofs ?? true,
})
}

/**
* Request authorization of a session allowing this agent to issue UCANs
* signed by the passed email address.
Expand All @@ -216,25 +287,13 @@ export async function authorizeAndWait(access, email, opts) {
* @param {object} [opts]
* @param {AbortSignal} [opts.signal]
* @param {Iterable<{ can: Ucanto.Ability }>} [opts.capabilities]
* @deprecated use authorizeWaitAndClaim directly going forward, passing it the expectAuthorization: waitForAuthorizationOnSocket to replicate this function's behavior
*/
export async function authorizeWithSocket(access, email, opts) {
const expectAuthorization = () =>
/** @type {Promise<[Ucanto.Delegation<[import('./types').AccessSession]>]>} */
(
waitForDelegationOnSocket(access, {
...opts,
signal: opts?.signal,
}).then((d) => {
return [d]
})
)
await authorizeAndWait(access, email, {
return authorizeWaitAndClaim(access, email, {
...opts,
expectAuthorization,
expectAuthorization: waitForAuthorizationOnSocket,
})
// claim delegations here because we will need an ucan/attest from the service to
// pair with the session delegation we just claimed to make it work
await claimAccess(access, access.issuer.did(), { addProofs: true })
}

/**
Expand Down
Loading

0 comments on commit 83971de

Please sign in to comment.