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

Add VC template reference issue request feature. #117

Merged
merged 5 commits into from
Oct 15, 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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# bedrock-vc-delivery ChangeLog

## 6.3.0 - 2024-10-dd

### Added
- Add `issueRequests` feature for expressing parameters for issuing VCs
in a particular step. The `issueRequest` value must be an array, with
each element containing parameters for issuing a VC. The parameters
must minimally include a credential template ID or index that
references a credential template from the associated workflow. The
parameters may optionally specify alternative variables to use when
evaluating the template, either via an object or a string, where
the string includes the name of a variable from the workflow's
main `variables`.

## 6.2.0 - 2024-10-02

### Changed
Expand Down
36 changes: 34 additions & 2 deletions lib/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ const ALLOWED_ERROR_KEYS = [
];

export async function evaluateTemplate({
workflow, exchange, typedTemplate
workflow, exchange, typedTemplate, variables
} = {}) {
// run jsonata compiler; only `jsonata` template type is supported and this
// assumes only this template type will be passed in
const {template} = typedTemplate;
variables = variables ?? getTemplateVariables({workflow, exchange});
return jsonata(template).evaluate(variables, variables);
}

export function getTemplateVariables({workflow, exchange} = {}) {
const {variables = {}} = exchange;
// always include `globals` as keyword for self-referencing exchange info
variables.globals = {
Expand All @@ -38,7 +43,7 @@ export async function evaluateTemplate({
id: exchange.id
}
};
return jsonata(template).evaluate(variables, variables);
return variables;
}

export function getWorkflowId({routePrefix, localId} = {}) {
Expand Down Expand Up @@ -207,6 +212,33 @@ export async function unenvelopePresentation({
return {presentation, ...result};
}

export async function validateStep({step} = {}) {
// FIXME: use `ajv` and do JSON schema check
if(Object.keys(step).length === 0) {
throw new BedrockError('Empty exchange step detected.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
if(step.issueRequests !== undefined && !Array.isArray(step.issueRequests)) {
throw new BedrockError(
'Invalid "issueRequests" in step.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
// use of `jwtDidProofRequest` and `openId` together is prohibited
const {jwtDidProofRequest, openId} = step;
if(jwtDidProofRequest && openId) {
throw new BedrockError(
'Invalid workflow configuration; only one of ' +
'"jwtDidProofRequest" and "openId" is permitted in a step.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
}

function _getEnvelope({envelope, format}) {
const isString = typeof envelope === 'string';
if(isString) {
Expand Down
103 changes: 82 additions & 21 deletions lib/issue.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,115 @@
/*!
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import {
evaluateTemplate,
getTemplateVariables,
getWorkflowIssuerInstances,
getZcapClient
} from './helpers.js';
import {createPresentation} from '@digitalbazaar/vc';

const {util: {BedrockError}} = bedrock;

export async function issue({
workflow, exchange, format = 'application/vc'
workflow, exchange, step, format = 'application/vc'
} = {}) {
// use any templates from workflow and variables from exchange to produce
// credentials to be issued; issue via the configured issuer instance
const verifiableCredential = [];
const {credentialTemplates = []} = workflow;
if(!credentialTemplates || credentialTemplates.length === 0) {
// nothing to issue
return {response: {}};
}

// evaluate template
const issueRequests = await Promise.all(credentialTemplates.map(
typedTemplate => evaluateTemplate({workflow, exchange, typedTemplate})));
// generate all issue requests for current step in exchange
const issueRequests = await _createIssueRequests({
workflow, exchange, step, credentialTemplates
});
// issue all VCs
const vcs = await _issue({workflow, issueRequests, format});
verifiableCredential.push(...vcs);

// generate VP to return VCs
const verifiablePresentation = createPresentation();
// FIXME: add any encrypted VCs to VP

// add any issued VCs to VP
if(verifiableCredential.length > 0) {
verifiablePresentation.verifiableCredential = verifiableCredential;
if(vcs.length > 0) {
verifiablePresentation.verifiableCredential = vcs;
}
return {response: {verifiablePresentation}, format};
}

async function _createIssueRequests({
workflow, exchange, step, credentialTemplates
}) {
// if step does not define `issueRequests`, then use all for templates for
// backwards compatibility
let params;
if(!step?.issueRequests) {
params = credentialTemplates.map(typedTemplate => ({typedTemplate}));
} else {
// resolve all issue requests params in parallel
const variables = getTemplateVariables({workflow, exchange});
params = await Promise.all(step.issueRequests.map(async r => {
// find the typed template to use
let typedTemplate;
if(r.credentialTemplateIndex !== undefined) {
typedTemplate = credentialTemplates[r.credentialTemplateIndex];
} else if(r.credentialTemplateId !== undefined) {
typedTemplate = credentialTemplates.find(
t => t.id === r.credentialTemplateId);
}
if(typedTemplate === undefined) {
throw new BedrockError(
'Credential template ' +
`"${r.credentialTemplateIndex ?? r.credentialTemplateId}" ` +
'not found.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}

// allow different variables to be specified for the typed template
let vars = variables;
if(r.variables !== undefined) {
if(typeof r.variables === 'string') {
vars = variables[r.variables];
} else {
vars = r.variables;
}
if(!(vars && typeof vars === 'object')) {
throw new BedrockError(
`Issue request variables "${r.variables}" not found or invalid.`, {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
}
return {
typedTemplate,
variables: {
// always include globals but allow local override
globals: variables.globals,
...vars
}
};
}));
}

// evaluate all issue requests
return Promise.all(params.map(({typedTemplate, variables}) =>
evaluateTemplate({workflow, exchange, typedTemplate, variables})));
}

function _getIssueZcap({workflow, zcaps, format}) {
const issuerInstances = getWorkflowIssuerInstances({workflow});
const {zcapReferenceIds: {issue: issueRefId}} = issuerInstances.find(
({supportedFormats}) => supportedFormats.includes(format));
return zcaps[issueRefId];
}

async function _issue({workflow, issueRequests, format} = {}) {
// create zcap client for issuing VCs
const {zcapClient, zcaps} = await getZcapClient({workflow});
Expand All @@ -53,10 +125,8 @@ async function _issue({workflow, issueRequests, format} = {}) {
'/issue' : '/credentials/issue';
}

const issuedVCs = [];

// issue VCs in parallel
await Promise.all(issueRequests.map(async issueRequest => {
return Promise.all(issueRequests.map(async issueRequest => {
/* Note: Issue request formats can be any one of these:

1. `{credential, options?}`
Expand All @@ -69,15 +139,6 @@ async function _issue({workflow, issueRequests, format} = {}) {
const {
data: {verifiableCredential}
} = await zcapClient.write({url, capability, json});
issuedVCs.push(verifiableCredential);
return verifiableCredential;
}));

return issuedVCs;
}

function _getIssueZcap({workflow, zcaps, format}) {
const issuerInstances = getWorkflowIssuerInstances({workflow});
const {zcapReferenceIds: {issue: issueRefId}} = issuerInstances.find(
({supportedFormats}) => supportedFormats.includes(format));
return zcaps[issueRefId];
}
27 changes: 6 additions & 21 deletions lib/oid4/oid4vci.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as bedrock from '@bedrock/core';
import * as exchanges from '../exchanges.js';
import {
deepEqual, evaluateTemplate, getWorkflowIssuerInstances
deepEqual, evaluateTemplate, getWorkflowIssuerInstances, validateStep
} from '../helpers.js';
import {importJWK, SignJWT} from 'jose';
import {checkAccessToken} from '@bedrock/oauth2-verifier';
Expand Down Expand Up @@ -427,33 +427,18 @@ async function _processExchange({
});

// process exchange step if present
let step;
const currentStep = exchange.step;
if(currentStep) {
let step = workflow.steps[exchange.step];
step = workflow.steps[exchange.step];
if(step.stepTemplate) {
// generate step from the template; assume the template type is
// `jsonata` per the JSON schema
step = await evaluateTemplate(
{workflow, exchange, typedTemplate: step.stepTemplate});
if(Object.keys(step).length === 0) {
throw new BedrockError('Could not create exchange step.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
}

// do late workflow configuration validation
const {jwtDidProofRequest, openId} = step;
// use of `jwtDidProofRequest` and `openId` together is prohibited
if(jwtDidProofRequest && openId) {
throw new BedrockError(
'Invalid workflow configuration; only one of ' +
'"jwtDidProofRequest" and "openId" is permitted in a step.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
await validateStep({step});
const {jwtDidProofRequest} = step;

// check to see if step supports OID4VP during OID4VCI
if(step.openId) {
Expand Down Expand Up @@ -518,7 +503,7 @@ async function _processExchange({
// replay attack detected) after exchange has been marked complete

// issue VCs
return issue({workflow, exchange, format});
return issue({workflow, exchange, step, format});
} catch(e) {
if(e.name === 'InvalidStateError') {
throw e;
Expand Down
11 changes: 4 additions & 7 deletions lib/oid4/oid4vp.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
import * as bedrock from '@bedrock/core';
import * as exchanges from '../exchanges.js';
import {evaluateTemplate, unenvelopePresentation} from '../helpers.js';
import {
evaluateTemplate, unenvelopePresentation, validateStep
} from '../helpers.js';
import {
presentationSubmission as presentationSubmissionSchema,
verifiablePresentation as verifiablePresentationSchema
Expand Down Expand Up @@ -50,13 +52,8 @@ export async function getAuthorizationRequest({req}) {
// `jsonata` per the JSON schema
step = await evaluateTemplate(
{workflow, exchange, typedTemplate: step.stepTemplate});
if(Object.keys(step).length === 0) {
throw new BedrockError('Could not create authorization request.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
}
await validateStep({step});

// step must have `openId` to perform OID4VP
if(!step.openId) {
Expand Down
11 changes: 3 additions & 8 deletions lib/vcapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as bedrock from '@bedrock/core';
import * as exchanges from './exchanges.js';
import {createChallenge as _createChallenge, verify} from './verify.js';
import {
evaluateTemplate, generateRandom, unenvelopePresentation
evaluateTemplate, generateRandom, unenvelopePresentation, validateStep
} from './helpers.js';
import {exportJWK, generateKeyPair, importJWK} from 'jose';
import {compile} from '@bedrock/validation';
Expand Down Expand Up @@ -121,13 +121,8 @@ export async function processExchange({req, res, workflow, exchangeRecord}) {
// `jsonata` per the JSON schema
step = await evaluateTemplate(
{workflow, exchange, typedTemplate: step.stepTemplate});
if(Object.keys(step).length === 0) {
throw new BedrockError('Empty step detected.', {
name: 'DataError',
details: {httpStatusCode: 500, public: true}
});
}
}
await validateStep({step});

// if next step is the same as the current step, throw an error
if(step.nextStep === currentStep) {
Expand Down Expand Up @@ -272,7 +267,7 @@ export async function processExchange({req, res, workflow, exchangeRecord}) {

// issue any VCs; may return an empty response if the step defines no
// VCs to issue
const {response} = await issue({workflow, exchange});
const {response} = await issue({workflow, exchange, step});

// if last `step` has a redirect URL, include it in the response
if(step?.redirectUrl) {
Expand Down
Loading