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

Client provided local workflow #64

Merged
merged 3 commits into from
May 20, 2024
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,9 @@ jobs:
run: |
cd test
npm run coverage-ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./test/coverage/lcov.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
credential request (i.e., including the `credential` param and
any other optional params) as an optional alternative to returning
only the value of the `credential` param for issuance.
- Allow clients to provide local workflow IDs as long as they meet
the local ID validation requirements. This is to enable clients to
ensure that they do not create duplicate workflows.

## 4.3.0 - 2023-12-11

Expand Down
55 changes: 52 additions & 3 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
/*!
* Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as exchangerSchemas from '../schemas/bedrock-vc-exchanger.js';
import {createService, schemas} from '@bedrock/service-core';
import {addRoutes} from './http.js';
import {initializeServiceAgent} from '@bedrock/service-agent';
import {klona} from 'klona';
import {parseLocalId} from './helpers.js';
import '@bedrock/express';

// load config defaults
import './config.js';

const routePrefix = '/exchangers';
const serviceType = 'vc-exchanger';
const {util: {BedrockError}} = bedrock;

Expand All @@ -31,10 +33,14 @@ bedrock.events.on('bedrock.init', async () => {
// schema.required.push('credentialTemplates');
}

// allow `id` property in `createConfigBody`, to be more rigorously validated
// below in `validateConfigFn`
createConfigBody.properties.id = updateConfigBody.properties.id;

// create `vc-exchanger` service
const service = await createService({
serviceType,
routePrefix: '/exchangers',
routePrefix,
storageCost: {
config: 1,
revocation: 1
Expand Down Expand Up @@ -84,8 +90,25 @@ async function usageAggregator({meter, signal, service} = {}) {
return service.configStorage.getUsage({meterId, signal});
}

async function validateConfigFn({config} = {}) {
async function validateConfigFn({config, op} = {}) {
try {
// validate any `id` in a new config
if(op === 'create' && config.id !== undefined) {
try {
_validateId({id: config.id});
} catch(e) {
throw new BedrockError(
`Invalid client-provided configuration ID: ${e.message}.`, {
name: 'DataError',
details: {
httpStatusCode: 400,
public: true
},
cause: e
});
}
}

// if credential templates are specified, then `zcaps` MUST include at
// least `issue`
const {credentialTemplates = [], zcaps = {}} = config;
Expand Down Expand Up @@ -118,3 +141,29 @@ async function validateConfigFn({config} = {}) {
}
return {valid: true};
}

function _validateId({id} = {}) {
// format: <base>/<localId>

// ensure `id` starts with appropriate base URL
const {baseUri} = bedrock.config.server;
const base = `${baseUri}${routePrefix}/`;
if(id.startsWith(base)) {
// ensure `id` ends with appropriate local ID
const expectedLastSlashIndex = base.length - 1;
const idx = id.lastIndexOf('/');
if(idx === expectedLastSlashIndex) {
return parseLocalId({id});
}
}

throw new BedrockError(
`Configuration "id" must start with "${base}" and end in a multibase, ` +
'base58-encoded local identifier.', {
name: 'DataError',
details: {
httpStatusCode: 400,
public: true
}
});
}
73 changes: 72 additions & 1 deletion test/mocha/10-provision.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2019-2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as helpers from './helpers.js';
Expand Down Expand Up @@ -54,6 +54,28 @@ describe('provision', () => {
const {id: capabilityAgentId} = capabilityAgent;
result.controller.should.equal(capabilityAgentId);
});
it('creates a config with a client-chosen ID', async () => {
let err;
let result;
try {
const localId = await helpers.generateRandom();
result = await helpers.createExchangerConfig({
capabilityAgent, configOptions: {
id: `${mockData.baseUrl}/exchangers/${localId}`
}
});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(result);
result.should.have.keys([
'controller', 'id', 'sequence', 'meterId'
]);
result.sequence.should.equal(0);
const {id: capabilityAgentId} = capabilityAgent;
result.controller.should.equal(capabilityAgentId);
});
it('creates a config with only an issue zcap', async () => {
let err;
let result;
Expand Down Expand Up @@ -147,6 +169,55 @@ describe('provision', () => {
'A capability to issue credentials is required when credential ' +
'templates are provided.');
});
it('throws if duplicate client-chosen ID is used', async () => {
let err;
let result;
try {
// create config (should pass)
result = await helpers.createExchangerConfig({
capabilityAgent, configOptions: {
id: `${mockData.baseUrl}/exchangers/z1A183gxYRXYFUnHUXsS7KVmA`
}
});
should.exist(result);
// try to create duplicate (should throw)
result = undefined;
result = await helpers.createExchangerConfig({
capabilityAgent, configOptions: {
id: `${mockData.baseUrl}/exchangers/z1A183gxYRXYFUnHUXsS7KVmA`
}
});
} catch(e) {
err = e;
}
should.exist(err);
should.not.exist(result);
should.exist(err.data);
err.status.should.equal(409);
err.data.name.should.equal('DuplicateError');
err.data.message.should.contain('Duplicate configuration');
});
it('throws if invalid client-chosen ID is used', async () => {
let err;
let result;
try {
result = await helpers.createExchangerConfig({
capabilityAgent, configOptions: {
id: `${mockData.baseUrl}/exchangers/foo`
}
});
} catch(e) {
err = e;
}
should.exist(err);
should.not.exist(result);
should.exist(err.data);
err.status.should.equal(400);
err.data.name.should.equal('DataError');
err.data.message.should.contain('Configuration validation failed');
err.data.details.cause.message.should.contain(
'Invalid client-provided configuration ID');
});
it('creates a config including proper ipAllowList', async () => {
const ipAllowList = ['127.0.0.1/32', '::1/128'];

Expand Down
26 changes: 13 additions & 13 deletions test/mocha/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function createCredentialOffer({
};

if(preAuthorized) {
exchange.openId.preAuthorizedCode = await _generateRandom();
exchange.openId.preAuthorizedCode = await generateRandom();
const grant = {'pre-authorized_code': exchange.openId.preAuthorizedCode};
offer.grants['urn:ietf:params:oauth:grant-type:pre-authorized_code'] =
grant;
Expand Down Expand Up @@ -172,10 +172,10 @@ export async function createConfig({

export async function createExchangerConfig({
capabilityAgent, ipAllowList, meterId, zcaps, credentialTemplates,
steps, initialStep, oauth2 = false
steps, initialStep, oauth2 = false,
configOptions = {credentialTemplates, steps, initialStep}
} = {}) {
const url = `${mockData.baseUrl}/exchangers`;
const configOptions = {credentialTemplates, steps, initialStep};
return createConfig({
serviceType: 'vc-exchanger',
url, capabilityAgent, ipAllowList, meterId, zcaps, configOptions, oauth2
Expand Down Expand Up @@ -462,6 +462,16 @@ export async function delegate({
});
}

export function generateRandom() {
// 128-bit random number, base58 multibase + multihash encoded
return generateId({
bitLength: 128,
encoding: 'base58',
multibase: true,
multihash: true
});
}

export async function getCredentialStatus({verifiableCredential}) {
// get SLC for the VC
const {credentialStatus} = verifiableCredential;
Expand Down Expand Up @@ -712,13 +722,3 @@ async function keyResolver({id}) {
const {data} = await httpClient.get(id, {agent: httpsAgent});
return data;
}

function _generateRandom() {
// 128-bit random number, base58 multibase + multihash encoded
return generateId({
bitLength: 128,
encoding: 'base58',
multibase: true,
multihash: true
});
}
Loading