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 interaction "protocols" URL #112

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

## 5.6.0 - 2024-09-dd

### Added
- Add interaction "protocols" URL support.

## 5.5.1 - 2024-09-05

### Fixed
Expand Down
48 changes: 47 additions & 1 deletion lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {getWorkflowId} from './helpers.js';
import {logger} from './logger.js';
import {createValidateMiddleware as validate} from '@bedrock/validation';

const {util: {BedrockError}} = bedrock;

// FIXME: remove and apply at top-level application
bedrock.events.on('bedrock-express.configure.bodyParser', app => {
app.use(bodyParser.json({
Expand All @@ -32,7 +34,8 @@ export async function addRoutes({app, service} = {}) {
const baseUrl = `${routePrefix}/:localId`;
const routes = {
exchanges: `${baseUrl}/exchanges`,
exchange: `${baseUrl}/exchanges/:exchangeId`
exchange: `${baseUrl}/exchanges/:exchangeId`,
protocols: `${baseUrl}/exchanges/:exchangeId/protocols`
};

// used to retrieve service object (workflow) config
Expand Down Expand Up @@ -117,6 +120,49 @@ export async function addRoutes({app, service} = {}) {
await processExchange({req, res, workflow, exchangeRecord});
}));

// VC-API get interaction `{"protocols": {...}}` options
app.get(
routes.protocols,
cors(),
getExchange,
getConfigMiddleware,
asyncHandler(async (req, res) => {
if(!req.accepts('json')) {
// provide hopefully useful error for when VC API interaction URLs
// are processed improperly, e.g., directly loaded by a browser instead
// of by a digital wallet
throw new BedrockError(
'Unsupported "Accept" header. A VC API interaction URL must be ' +
'processed by an exchange client, e.g., a digital wallet.', {
name: 'NotSupportedError',
details: {httpStatusCode: 406, public: true}
});
}
// construct and return `protocols` object
const {config: workflow} = req.serviceObject;
const {exchange} = await req.getExchange();
const exchangeId = `${workflow.id}/exchanges/${exchange.id}`;
const protocols = {
vcapi: exchangeId
};
const openIdRoute = `${exchangeId}/openid`;
if(oid4.supportsOID4VCI({exchange})) {
// OID4VCI supported; add credential offer URL
const searchParams = new URLSearchParams();
const uri = `${openIdRoute}/credential-offer`;
searchParams.set('credential_offer_uri', uri);
protocols.OID4VCI = `openid-credential-offer://?${searchParams}`;
} else if(await oid4.supportsOID4VP({workflow, exchange})) {
// OID4VP supported; add openid4vp URL
const searchParams = new URLSearchParams({
client_id: `${openIdRoute}/client/authorization/response`,
request_uri: `${openIdRoute}/client/authorization/request`
});
protocols.OID4VP = `openid4vp://authorize?${searchParams}`;
}
res.json({protocols});
}));

// create OID4* routes to be used with each individual exchange
await oid4.createRoutes(
{app, exchangeRoute: routes.exchange, getConfigMiddleware, getExchange});
Expand Down
4 changes: 4 additions & 0 deletions lib/oid4/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {logger} from '../logger.js';
import {UnsecuredJWT} from 'jose';
import {createValidateMiddleware as validate} from '@bedrock/validation';

// re-export support detection helpers
export {supportsOID4VCI} from './oid4vci.js';
export {supportsOID4VP} from './oid4vp.js';

/* NOTE: Parts of the OID4VCI design imply tight integration between the
authorization server and the credential issuance / delivery server. This
file provides the routes for both and treats them as integrated; supporting
Expand Down
6 changes: 5 additions & 1 deletion lib/oid4/oid4vci.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ export async function processCredentialRequests({req, res, isBatchRequest}) {
return _processExchange({req, res, workflow, exchangeRecord, isBatchRequest});
}

export function supportsOID4VCI({exchange}) {
return exchange.openId?.expectedCredentialRequests !== undefined;
}

function _assertCredentialRequests({
workflow, credentialRequests, expectedCredentialRequests
}) {
Expand Down Expand Up @@ -201,7 +205,7 @@ function _assertCredentialRequests({
}

function _assertOID4VCISupported({exchange}) {
if(!exchange.openId?.expectedCredentialRequests) {
if(!supportsOID4VCI({exchange})) {
throw new BedrockError('OID4VCI is not supported by this exchange.', {
name: 'NotSupportedError',
details: {httpStatusCode: 400, public: true}
Expand Down
12 changes: 12 additions & 0 deletions lib/oid4/oid4vp.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,18 @@ export async function processAuthorizationResponse({req}) {
}
}

export async function supportsOID4VP({workflow, exchange}) {
if(!exchange.step) {
return false;
}
let step = workflow.steps[exchange.step];
if(step.stepTemplate) {
step = await evaluateTemplate(
{workflow, exchange, typedTemplate: step.stepTemplate});
}
return step.openId !== undefined;
}

function _createClientMetaData() {
// return default supported `vp_formats`
return {
Expand Down
39 changes: 38 additions & 1 deletion test/mocha/30-oid4vci.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
parseCredentialOfferUrl
} from '@digitalbazaar/oid4-client';
import {agent} from '@bedrock/https-agent';
import {httpClient} from '@digitalbazaar/http-client';
import {mockData} from './mock.data.js';
import {v4 as uuid} from 'uuid';

Expand Down Expand Up @@ -253,7 +254,10 @@ describe('exchange w/OID4VCI delivery', () => {

// pre-authorized flow, issuer-initiated
const credentialId = `urn:uuid:${uuid()}`;
const {openIdUrl: offerUrl} = await helpers.createCredentialOffer({
const {
exchangeId,
openIdUrl: offerUrl
} = await helpers.createCredentialOffer({
// local target user
userId: 'urn:uuid:01cc3771-7c51-47ab-a3a3-6d34b47ae3c4',
credentialDefinition: mockData.credentialDefinition,
Expand All @@ -278,6 +282,39 @@ describe('exchange w/OID4VCI delivery', () => {
url: parsedChapiRequest.OID4VC, agent
});

// confirm offer URL matches the one in `protocols`
{
const protocolsUrl = `${exchangeId}/protocols`;
const response = await httpClient.get(protocolsUrl, {agent});
should.exist(response);
should.exist(response.data);
should.exist(response.data.protocols);
should.exist(response.data.protocols.vcapi);
response.data.protocols.vcapi.should.equal(exchangeId);
should.exist(response.data.protocols.OID4VCI);
response.data.protocols.OID4VCI.should.equal(offerUrl);
}

// confirm 406 when not requesting JSON
{
const protocolsUrl = `${exchangeId}/protocols`;
let response;
let error;
try {
response = await httpClient.get(protocolsUrl, {
agent,
headers: {
accept: 'text/html'
}
});
} catch(e) {
error = e;
}
should.not.exist(response);
should.exist(error);
error.status.should.equal(406);
}

// wallet / client gets access token
const client = await OID4Client.fromCredentialOffer({offer, agent});

Expand Down
26 changes: 19 additions & 7 deletions test/mocha/34-oid4vp.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,25 @@ describe('exchange w/ OID4VP presentation w/VC', () => {
});
const authzReqUrl = `${exchangeId}/openid/client/authorization/request`;

// `openid4vp` URL would be:
/*const searchParams = new URLSearchParams({
client_id: `${exchangeId}/openid/client/authorization/response`,
request_uri: authzReqUrl
});
const openid4vpUrl = 'openid4vp://authorize?' + searchParams.toString();
console.log('openid4vpUrl', openid4vpUrl);*/
// confirm oid4vp URL matches the one in `protocols`
{
// `openid4vp` URL would be:
const searchParams = new URLSearchParams({
client_id: `${exchangeId}/openid/client/authorization/response`,
request_uri: authzReqUrl
});
const openid4vpUrl = 'openid4vp://authorize?' + searchParams.toString();

const protocolsUrl = `${exchangeId}/protocols`;
const response = await httpClient.get(protocolsUrl, {agent});
should.exist(response);
should.exist(response.data);
should.exist(response.data.protocols);
should.exist(response.data.protocols.vcapi);
response.data.protocols.vcapi.should.equal(exchangeId);
should.exist(response.data.protocols.OID4VP);
response.data.protocols.OID4VP.should.equal(openid4vpUrl);
}

// get authorization request
const {authorizationRequest} = await getAuthorizationRequest(
Expand Down