Skip to content

Commit

Permalink
feat: configurable audience handlers (#257)
Browse files Browse the repository at this point in the history
* feat: configurable audience handlers

* chore: add some doc comments
  • Loading branch information
Gozala authored Mar 13, 2023
1 parent 7744f57 commit f8d001c
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 28 deletions.
2 changes: 0 additions & 2 deletions packages/interface/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,6 @@ export interface MalformedCapability extends Failure {

export interface InvalidAudience extends Failure {
readonly name: 'InvalidAudience'
readonly audience: UCAN.Principal
readonly delegation: Delegation
}

export interface UnavailableProof extends Failure {
Expand Down
70 changes: 67 additions & 3 deletions packages/server/src/handler.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as API from './api.js'
import { access } from '@ucanto/validator'
import { access, Schema, Failure } from '@ucanto/validator'

/**
* Function that can be used to define given capability provider. It decorates
* passed handler and takes care of UCAN validation and only calls the handler
* when validation succeeds.
*
*
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
Expand All @@ -11,13 +16,44 @@ import { access } from '@ucanto/validator'
* @returns {API.ServiceMethod<API.Capability<A, R, C>, Exclude<U, {error:true}>, Exclude<U, Exclude<U, {error:true}>>>}
*/

export const provide =
(capability, handler) =>
export const provide = (capability, handler) =>
provideAdvanced({ capability, handler })

/**
* Function that can be used to define given capability provider. It decorates
* passed handler and takes care of UCAN validation and only calls the handler
* when validation succeeds. This is an advanced version of `provide` function
* which allowing you to pass additional `input.audience` schema so that handler
* could accept invocations for audiences other than the service itself. If
* `input.audience` is not provided behavior is the same as `provide` function.
*
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @template {unknown} U
* @param {object} input
* @param {API.Reader<API.DID>} [input.audience]
* @param {API.CapabilityParser<API.Match<API.ParsedCapability<A, R, C>>>} input.capability
* @param {(input:API.ProviderInput<API.ParsedCapability<A, R, C>>) => API.Await<U>} input.handler
* @returns {API.ServiceMethod<API.Capability<A, R, C>, Exclude<U, {error:true}>, Exclude<U, Exclude<U, {error:true}>>>}
*/

export const provideAdvanced =
({ capability, handler, audience }) =>
/**
* @param {API.Invocation<API.Capability<A, R, C>>} invocation
* @param {API.InvocationContext} options
*/
async (invocation, options) => {
// If audience schema is not provided we expect the audience to match
// the server id. Users could pass `schema.string()` if they want to accept
// any audience.
const audienceSchema = audience || Schema.literal(options.id.did())
const result = audienceSchema.read(invocation.audience.did())
if (result.error) {
return new InvalidAudience({ cause: result })
}

const authorization = await access(invocation, {
...options,
authority: options.id,
Expand All @@ -35,3 +71,31 @@ export const provide =
)
}
}

/**
* @implements {API.InvalidAudience}
*/
class InvalidAudience extends Failure {
/**
* @param {object} source
* @param {API.Failure} source.cause
*/
constructor({ cause }) {
super()
/** @type {'InvalidAudience'} */
this.name = 'InvalidAudience'
this.cause = cause
}
describe() {
return this.cause.message
}
toJSON() {
const { error, name, message, stack } = this
return {
error,
name,
message,
stack,
}
}
}
8 changes: 0 additions & 8 deletions packages/server/src/server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as API from '@ucanto/interface'
import { InvalidAudience } from '@ucanto/validator'
import { Verifier } from '@ucanto/principal'
export {
capability,
Expand Down Expand Up @@ -99,13 +98,6 @@ export const execute = async (invocations, server) => {
* @returns {Promise<API.InferServiceInvocationReturn<C, Service>>}
*/
export const invoke = async (invocation, server) => {
// If invocation is not for our server respond with error
if (invocation.audience.did() !== server.id.did()) {
return /** @type {API.Result<any, API.InvalidAudience>} */ (
new InvalidAudience(server.id, invocation)
)
}

// Invocation needs to have one single capability
if (invocation.capabilities.length !== 1) {
return /** @type {API.Result<any, InvocationCapabilityError>} */ (
Expand Down
99 changes: 92 additions & 7 deletions packages/server/test/handler.spec.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import * as Client from '@ucanto/client'
import * as Server from '../src/server.js'
import * as Provider from '../src/handler.js'
import * as CAR from '@ucanto/transport/car'
import * as CBOR from '@ucanto/transport/cbor'
import * as API from '@ucanto/interface'
import { alice, bob, mallory, service as w3 } from './fixtures.js'
import { alice, bob, mallory, service } from './fixtures.js'
import { test, assert } from './test.js'
import * as Access from './service/access.js'
import { Verifier } from '@ucanto/principal/ed25519'
import { UnavailableProof } from '@ucanto/validator'
import { Schema, UnavailableProof } from '@ucanto/validator'
import { Absentee } from '@ucanto/principal'

const w3 = service.withDID('did:web:web3.storage')

const context = {
id: w3,
Expand All @@ -29,7 +33,7 @@ const context = {
test('invocation', async () => {
const invocation = await Client.delegate({
issuer: alice,
audience: bob,
audience: w3,
capabilities: [
{
can: 'identity/link',
Expand Down Expand Up @@ -63,7 +67,7 @@ test('delegated invocation fail', async () => {

const invocation = await Client.delegate({
issuer: alice,
audience: bob,
audience: w3,
capabilities: proof.capabilities,
proofs: [proof],
})
Expand All @@ -90,7 +94,7 @@ test('delegated invocation fail', async () => {

const invocation = await Client.delegate({
issuer: alice,
audience: bob,
audience: w3,
capabilities: proof.capabilities,
proofs: [proof],
})
Expand Down Expand Up @@ -138,9 +142,17 @@ test('checks service id', async () => {
assert.deepNestedInclude(result, {
error: true,
name: 'InvalidAudience',
audience: w3.did(),
delegation: { audience: mallory.did() },
})
assert.equal(
result?.message.includes(w3.did()),
true,
'mentions expected audience'
)
assert.equal(
result?.message.includes(mallory.did()),
true,
'mentions actual audience'
)
}

{
Expand Down Expand Up @@ -242,3 +254,76 @@ test('test access/claim provider', async () => {
const result = await claim.execute(client)
assert.deepEqual(result, [])
})

test('handle did:mailto audiences', async () => {
const AccessRequest = Server.capability({
can: 'access/request',
with: Schema.did(),
nb: Schema.struct({
need: Schema.dictionary({
key: Schema.string(),
value: Schema.unknown().array(),
}),
}),
})

const handler = Provider.provideAdvanced({
audience: Schema.did({ method: 'mailto' }),
capability: AccessRequest,
handler: async input => {
return {
allow: input.capability.nb.need,
}
},
})

const request = await Client.delegate({
issuer: alice,
audience: Absentee.from({ id: 'did:mailto:web.mail:alice' }),
capabilities: [
{
can: 'access/request',
with: alice.did(),
nb: {
need: {
'store/*': [],
},
},
},
],
})

const result = await handler(request, {
id: w3,
principal: Verifier,
})

assert.equal(result.error, undefined)

const badRequest = await Client.delegate({
issuer: alice,
audience: w3,
capabilities: [
{
can: 'access/request',
with: alice.did(),
nb: {
need: {
'store/*': [],
},
},
},
],
})

const badAudience = await handler(badRequest, {
id: w3,
principal: Verifier,
})

assert.equal(badAudience.error, true)
assert.match(
badAudience.toString(),
/InvalidAudience.*Expected .*did:mailto:.*got.*did:web:/
)
})
2 changes: 1 addition & 1 deletion packages/validator/src/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export class DIDKeyResolutionError extends Failure {
/**
* @implements {API.InvalidAudience}
*/
export class InvalidAudience extends Failure {
export class PrincipalAlignmentError extends Failure {
/**
* @param {API.UCAN.Principal} audience
* @param {API.Delegation} delegation
Expand Down
11 changes: 6 additions & 5 deletions packages/validator/src/lib.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as API from '@ucanto/interface'
import { isDelegation, Delegation, UCAN } from '@ucanto/core'
import { isDelegation, UCAN } from '@ucanto/core'
import { capability } from './capability.js'
import * as Schema from './schema.js'
import {
UnavailableProof,
InvalidAudience,
PrincipalAlignmentError,
Expired,
NotValidBefore,
InvalidSignature,
Expand Down Expand Up @@ -146,7 +146,10 @@ const resolveSources = async ({ delegation }, config) => {
// If proof does not delegate to a matching audience save an proof error.
if (delegation.issuer.did() !== proof.audience.did()) {
errors.push(
new ProofError(proof.cid, new InvalidAudience(delegation.issuer, proof))
new ProofError(
proof.cid,
new PrincipalAlignmentError(delegation.issuer, proof)
)
)
} else {
proofs.push(proof)
Expand Down Expand Up @@ -592,5 +595,3 @@ const verifySession = async (delegation, proofs, config) => {
config
)
}

export { InvalidAudience }
4 changes: 2 additions & 2 deletions packages/validator/test/error.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, assert } from './test.js'
import * as API from '@ucanto/interface'
import {
Failure,
InvalidAudience,
PrincipalAlignmentError,
InvalidSignature,
Expired,
NotValidBefore,
Expand Down Expand Up @@ -36,7 +36,7 @@ test('InvalidAudience', async () => {
proofs: [],
})

const error = new InvalidAudience(bob, delegation)
const error = new PrincipalAlignmentError(bob, delegation)

assert.deepEqual(error.toJSON(), {
error: true,
Expand Down

0 comments on commit f8d001c

Please sign in to comment.