diff --git a/CHANGELOG.md b/CHANGELOG.md index aa40992..35ad033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/http.js b/lib/http.js index 879c09b..834b4df 100644 --- a/lib/http.js +++ b/lib/http.js @@ -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({ @@ -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 @@ -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}); diff --git a/lib/oid4/http.js b/lib/oid4/http.js index 78a5cfd..4518dc2 100644 --- a/lib/oid4/http.js +++ b/lib/oid4/http.js @@ -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 diff --git a/lib/oid4/oid4vci.js b/lib/oid4/oid4vci.js index c4c4c42..a69a921 100644 --- a/lib/oid4/oid4vci.js +++ b/lib/oid4/oid4vci.js @@ -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 }) { @@ -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} diff --git a/lib/oid4/oid4vp.js b/lib/oid4/oid4vp.js index b1c05ca..f8260b7 100644 --- a/lib/oid4/oid4vp.js +++ b/lib/oid4/oid4vp.js @@ -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 { diff --git a/test/mocha/30-oid4vci.js b/test/mocha/30-oid4vci.js index 0a79c9d..32e3b40 100644 --- a/test/mocha/30-oid4vci.js +++ b/test/mocha/30-oid4vci.js @@ -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'; @@ -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, @@ -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}); diff --git a/test/mocha/34-oid4vp.js b/test/mocha/34-oid4vp.js index 74a10f7..a6d66db 100644 --- a/test/mocha/34-oid4vp.js +++ b/test/mocha/34-oid4vp.js @@ -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(