Skip to content

Commit

Permalink
feat: add revocation checker hook (#320)
Browse files Browse the repository at this point in the history
* feat: add revocation checker hook

* fix: tests

* fix: failing tests
  • Loading branch information
Gozala authored Oct 5, 2023
1 parent c8999a5 commit 0c2dbc6
Show file tree
Hide file tree
Showing 13 changed files with 385 additions and 92 deletions.
30 changes: 28 additions & 2 deletions packages/interface/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
IssuedInvocationView,
UCANOptions,
Verifier,
Unit,
} from './lib.js'

export interface Source {
Expand Down Expand Up @@ -350,6 +351,12 @@ export interface ProofResolver extends PrincipalOptions {
resolve?: (proof: Link) => Await<Result<Delegation, UnavailableProof>>
}

export interface RevocationChecker {
validateAuthorization: (
authorization: Authorization
) => Await<Result<Unit, Revoked>>
}

export interface Validator {
/**
* Validator must be provided a `Verifier` corresponding to local authority.
Expand All @@ -368,7 +375,8 @@ export interface ValidationOptions<
Validator,
PrincipalOptions,
PrincipalResolver,
ProofResolver {
ProofResolver,
RevocationChecker {
capability: CapabilityParser<Match<C, any>>
}

Expand All @@ -377,7 +385,8 @@ export interface ClaimOptions
Validator,
PrincipalOptions,
PrincipalResolver,
ProofResolver {}
ProofResolver,
RevocationChecker {}

export interface DelegationError extends Failure {
name: 'InvalidClaim'
Expand Down Expand Up @@ -425,6 +434,11 @@ export interface Expired extends Failure {
readonly expiredAt: number
}

export interface Revoked extends Failure {
readonly name: 'Revoked'
readonly delegation: Delegation
}

export interface NotValidBefore extends Failure {
readonly name: 'NotValidBefore'
readonly delegation: Delegation
Expand All @@ -449,6 +463,7 @@ export interface SessionEscalation extends Failure {
*/
export type InvalidProof =
| Expired
| Revoked
| NotValidBefore
| InvalidSignature
| InvalidAudience
Expand All @@ -465,6 +480,17 @@ export interface Unauthorized extends Failure {
failedProofs: InvalidClaim[]
}

export interface Authorization<
Capability extends ParsedCapability = ParsedCapability
> {
delegation: Delegation
capability: Capability

proofs: Authorization[]
issuer: UCAN.Principal
audience: UCAN.Principal
}

export interface InvalidClaim extends Failure {
issuer: UCAN.Principal
name: 'InvalidClaim'
Expand Down
3 changes: 3 additions & 0 deletions packages/interface/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ import {
DIDKeyResolutionError,
ParsedCapability,
CapabilityParser,
Revoked,
InferCapability,
Authorization,
} from './capability.js'
import type * as Transport from './transport.js'
import type { Tuple, Block } from './transport.js'
Expand Down Expand Up @@ -974,6 +976,7 @@ export interface ValidatorOptions {

readonly canIssue?: CanIssue['canIssue']
readonly resolve?: InvocationContext['resolve']
validateAuthorization: (proofs: Authorization) => Await<Result<Unit, Revoked>>
}

export interface ServerOptions extends ValidatorOptions {
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const provideAdvanced =
authority: options.id,
capability,
})

if (authorization.error) {
return authorization
} else {
Expand Down
8 changes: 7 additions & 1 deletion packages/server/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export { fail }
* @param {API.Server<Service>} options
* @returns {API.ServerView<Service>}
*/
export const create = options => new Server(options)
export const create = options => {
const server = new Server(options)
return server
}

/**
* @template {Record<string, any>} S
Expand All @@ -33,6 +36,9 @@ class Server {
this.service = service
this.codec = codec
this.catch = fail || (() => {})
this.validateAuthorization = this.context.validateAuthorization.bind(
this.context
)
}
get id() {
return this.context.id
Expand Down
45 changes: 44 additions & 1 deletion packages/server/test/handler.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ 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 { Schema, UnavailableProof } from '@ucanto/validator'
import {
Schema,
UnavailableProof,
Unauthorized,
Revoked,
} from '@ucanto/validator'
import { Absentee } from '@ucanto/principal'
import { capability } from '../src/server.js'
import { isLink, parseLink, fail } from '../src/lib.js'
Expand All @@ -31,6 +36,7 @@ const context = {
resolve: link => ({
error: new UnavailableProof(link),
}),
validateAuthorization: () => ({ ok: {} }),
}

test('invocation', async () => {
Expand Down Expand Up @@ -113,6 +119,7 @@ test('checks service id', async () => {
id: w3,
service: { identity: Access },
codec: CAR.inbound,
validateAuthorization: () => ({ ok: {} }),
})

const client = Client.connect({
Expand Down Expand Up @@ -186,6 +193,7 @@ test('checks for single capability invocation', async () => {
id: w3,
service: { identity: Access },
codec: CAR.inbound,
validateAuthorization: () => ({ ok: {} }),
})

const client = Client.connect({
Expand Down Expand Up @@ -237,6 +245,7 @@ test('test access/claim provider', async () => {
id: w3,
service: { access: Access },
codec: CAR.inbound,
validateAuthorization: () => ({ ok: {} }),
})

/**
Expand Down Expand Up @@ -305,6 +314,7 @@ test('handle did:mailto audiences', async () => {
const result = await handler(request, {
id: w3,
principal: Verifier,
validateAuthorization: () => ({ ok: {} }),
})

assert.equal(result.error, undefined)
Expand All @@ -328,6 +338,7 @@ test('handle did:mailto audiences', async () => {
const badAudience = await handler(badRequest, {
id: w3,
principal: Verifier,
validateAuthorization: () => ({ ok: {} }),
})

assert.containSubset(badAudience, {
Expand Down Expand Up @@ -661,6 +672,37 @@ test('fx.ok API', () => {
)
})

test('invocation fails if proof is revoked', async () => {
const proof = await Client.delegate({
issuer: w3,
audience: alice,
capabilities: [
{
can: 'identity/register',
with: 'mailto:[email protected]',
},
],
})

const invocation = await Client.delegate({
issuer: alice,
audience: w3,
capabilities: proof.capabilities,
proofs: [proof],
})

const result = await Access.register(invocation, {
...context,
validateAuthorization: auth => {
assert.deepEqual(auth.delegation.cid, invocation.cid)
assert.deepEqual(auth.delegation.proofs, [proof])
return { error: new Revoked(proof) }
},
})

assert.match(String(result.error), /Proof bafy.* has been revoked/)
})

/**
* @template {Record<string, any>} Service
* @param {Service} service
Expand All @@ -670,6 +712,7 @@ const setup = service => {
id: w3,
service,
codec: CAR.inbound,
validateAuthorization: () => ({ ok: {} }),
})

const consumer = Client.connect({
Expand Down
7 changes: 7 additions & 0 deletions packages/server/test/server.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ test('encode delegated invocation', async () => {
service: Service.create(),
codec: CAR.inbound,
id: w3,
validateAuthorization: () => ({ ok: {} }),
})

const connection = Client.connect({
Expand Down Expand Up @@ -184,6 +185,7 @@ test('unknown handler', async () => {
id: w3,
service: Service.create(),
codec: CAR.inbound,
validateAuthorization: () => ({ ok: {} }),
})

const connection = Client.connect({
Expand Down Expand Up @@ -258,6 +260,7 @@ test('execution error', async () => {
},
codec: CAR.inbound,
id: w3,
validateAuthorization: () => ({ ok: {} }),
})

const connection = Client.connect({
Expand Down Expand Up @@ -306,6 +309,7 @@ test('did:web server', async () => {
service: Service.create(),
codec: CAR.inbound,
id: w3.withDID('did:web:web3.storage'),
validateAuthorization: () => ({ ok: {} }),
})

const connection = Client.connect({
Expand Down Expand Up @@ -346,6 +350,7 @@ test('unsupported content-type', async () => {
'application/car': CAR.response,
},
}),
validateAuthorization: () => ({ ok: {} }),
})

const connection = Client.connect({
Expand Down Expand Up @@ -399,6 +404,7 @@ test('falsy errors are turned into {}', async () => {
},
id: w3.withDID('did:web:web3.storage'),
codec: CAR.inbound,
validateAuthorization: () => ({ ok: {} }),
})

const connection = Client.connect({
Expand Down Expand Up @@ -426,6 +432,7 @@ test('run invocation without encode / decode', async () => {
service: Service.create(),
codec: CAR.inbound,
id: w3,
validateAuthorization: () => ({ ok: {} }),
})

const identify = invoke({
Expand Down
48 changes: 48 additions & 0 deletions packages/validator/src/authorization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as API from '@ucanto/interface'

/**
* @template {API.ParsedCapability} C
* @implements {API.Authorization<C>}
*/
class Authorization {
/**
* @param {API.Match<C>} match
* @param {API.Authorization<API.ParsedCapability>[]} proofs
*/
constructor(match, proofs) {
this.match = match
this.proofs = proofs
}
get capability() {
return this.match.value
}
get delegation() {
return this.match.source[0].delegation
}
get issuer() {
return this.delegation.issuer
}
get audience() {
return this.delegation.audience
}
}

/**
* @template {API.ParsedCapability} C
* @param {API.Match<C>} match
* @param {API.Authorization<API.ParsedCapability>[]} proofs
* @returns {API.Authorization<C>}
*/
export const create = (match, proofs = []) => new Authorization(match, proofs)

/**
*
* @param {API.Authorization} authorization
* @returns {Iterable<API.UCANLink>}
*/
export const iterate = function* ({ delegation, proofs }) {
yield delegation.cid
for (const proof of proofs) {
yield* iterate(proof)
}
}
Loading

0 comments on commit 0c2dbc6

Please sign in to comment.